This commit is contained in:
2025-08-21 17:26:40 +02:00
parent 58ed59cd12
commit 095a4d0c69
96 changed files with 1070 additions and 10 deletions

View File

@@ -0,0 +1,345 @@
### 2.1 Accounts
* **id**: `BIGINT` identity (non-negative), unique account id
* **pubkey**: `BYTEA` unique public key for signing/encryption
* **display\_name**: `TEXT` (optional)
* **created\_at**: `TIMESTAMPTZ`
### 2.2 Currencies
* **asset\_code**: `TEXT` PK (e.g., `USDC-ETH`, `EUR`, `LND`)
* **name**: `TEXT`
* **symbol**: `TEXT`
* **decimals**: `INT` (default 2)
---
## 3) Services & Groups
### 3.1 Services
* **id**: `BIGINT` identity
* **name**: `TEXT` unique
* **description**: `TEXT`
* **default\_billing\_mode**: `ENUM('per_second','per_request')`
* **default\_price**: `NUMERIC(38,18)` (≥0)
* **default\_currency**: FK → `currencies(asset_code)`
* **max\_request\_seconds**: `INT` (>0 or `NULL`)
* **schema\_heroscript**: `TEXT`
* **schema\_json**: `JSONB`
* **created\_at**: `TIMESTAMPTZ`
#### Accepted Currencies (per service)
* **service\_id**: FK → `services(id)`
* **asset\_code**: FK → `currencies(asset_code)`
* **price\_override**: `NUMERIC(38,18)` (optional)
* **billing\_mode\_override**: `ENUM` (optional)
Primary key: `(service_id, asset_code)`
### 3.2 Service Groups
* **id**: `BIGINT` identity
* **name**: `TEXT` unique
* **description**: `TEXT`
* **created\_at**: `TIMESTAMPTZ`
#### Group Memberships
* **group\_id**: FK → `service_groups(id)`
* **service\_id**: FK → `services(id)`
Primary key: `(group_id, service_id)`
---
## 4) Providers & Runners
### 4.1 Service Providers
* **id**: `BIGINT` identity
* **account\_id**: FK → `accounts(id)` (the owning account)
* **name**: `TEXT` unique
* **description**: `TEXT`
* **created\_at**: `TIMESTAMPTZ`
#### Providers Offer Groups
* **provider\_id**: FK → `service_providers(id)`
* **group\_id**: FK → `service_groups(id)`
Primary key: `(provider_id, group_id)`
#### Provider Pricing Overrides (optional)
* **provider\_id**: FK → `service_providers(id)`
* **service\_id**: FK → `services(id)`
* **asset\_code**: FK → `currencies(asset_code)` (nullable for currency-agnostic override)
* **price\_override**: `NUMERIC(38,18)` (optional)
* **billing\_mode\_override**: `ENUM` (optional)
* **max\_request\_seconds\_override**: `INT` (optional)
Primary key: `(provider_id, service_id, asset_code)`
### 4.2 Runners
* **id**: `BIGINT` identity
* **address**: `INET` (must be IPv6)
* **name**: `TEXT`
* **description**: `TEXT`
* **pubkey**: `BYTEA` (optional)
* **created\_at**: `TIMESTAMPTZ`
#### Runner Ownership (many-to-many)
* **runner\_id**: FK → `runners(id)`
* **provider\_id**: FK → `service_providers(id)`
Primary key: `(runner_id, provider_id)`
#### Routing (provider → service/service\_group → runners)
* **provider\_service\_runners**: `(provider_id, service_id, runner_id)` PK
* **provider\_service\_group\_runners**: `(provider_id, group_id, runner_id)` PK
---
## 5) Subscriptions & Spend Control
A subscription authorizes an **account** to use either a **service** **or** a **service group**, with optional spend limits and allowed providers.
* **id**: `BIGINT` identity
* **account\_id**: FK → `accounts(id)`
* **service\_id** *xor* **group\_id**: FK (exactly one must be set)
* **secret**: `BYTEA` (random, provided by subscriber; recommend storing a hash)
* **subscription\_data**: `JSONB` (free-form)
* **limit\_amount**: `NUMERIC(38,18)` (optional)
* **limit\_currency**: FK → `currencies(asset_code)` (optional)
* **limit\_period**: `ENUM('hour','day','month')` (optional)
* **active**: `BOOLEAN` default `TRUE`
* **created\_at**: `TIMESTAMPTZ`
#### Allowed Providers per Subscription
* **subscription\_id**: FK → `subscriptions(id)`
* **provider\_id**: FK → `service_providers(id)`
Primary key: `(subscription_id, provider_id)`
**Intended Use:**
* Subscribers bound spending by amount/currency/period.
* Merchant (provider) can claim charges for requests fulfilled under an active subscription, within limits, and only if listed in `subscription_providers`.
---
## 6) Requests & Billing
### 6.1 Request Lifecycle
* **id**: `BIGINT` identity
* **account\_id**: FK → `accounts(id)`
* **subscription\_id**: FK → `subscriptions(id)`
* **provider\_id**: FK → `service_providers(id)`
* **service\_id**: FK → `services(id)`
* **runner\_id**: FK → `runners(id)` (nullable)
* **request\_schema**: `JSONB` (payload matching `schema_json`/`schema_heroscript`)
* **started\_at**, **ended\_at**: `TIMESTAMPTZ`
* **status**: `ENUM('pending','running','succeeded','failed','canceled')`
* **created\_at**: `TIMESTAMPTZ`
### 6.2 Billing Ledger (append-only)
* **id**: `BIGINT` identity
* **account\_id**: FK → `accounts(id)`
* **provider\_id**: FK → `service_providers(id)` (nullable)
* **service\_id**: FK → `services(id)` (nullable)
* **request\_id**: FK → `requests(id)` (nullable)
* **amount**: `NUMERIC(38,18)` (debit = positive, credit/refund = negative)
* **asset\_code**: FK → `currencies(asset_code)`
* **entry\_type**: `ENUM('debit','credit','adjustment')`
* **description**: `TEXT`
* **created\_at**: `TIMESTAMPTZ`
**Balances View (example):**
* `account_balances(account_id, asset_code, balance)` as a view over `billing_ledger`.
---
## 7) Pricing Precedence
When computing the **effective** pricing, billing mode, and max duration for a `(provider, service, currency)`:
1. **Provider override for (service, asset\_code)** — if present, use it.
2. **Service accepted currency override** — if present, use it.
3. **Service defaults** — fallback.
If `billing_mode` or `max_request_seconds` are not overridden at steps (1) or (2), inherit from the next step down.
---
## 8) Key Constraints & Validations
* All identity ids are non-negative (`CHECK (id >= 0)`).
* Runner IPv6 enforcement: `CHECK (family(address) = 6)`.
* Subscriptions must point to **exactly one** of `service_id` or `group_id`.
* Prices and limits must be non-negative if set.
* Unique natural keys where appropriate: service names, provider names, currency asset codes, account pubkeys.
---
## 9) Mermaid Diagrams
### 9.1 EntityRelationship Overview
```mermaid
erDiagram
ACCOUNTS ||--o{ SERVICE_PROVIDERS : "owns via account_id"
ACCOUNTS ||--o{ SUBSCRIPTIONS : has
CURRENCIES ||--o{ SERVICES : "default_currency"
CURRENCIES ||--o{ SERVICE_ACCEPTED_CURRENCIES : "asset_code"
CURRENCIES ||--o{ PROVIDER_SERVICE_OVERRIDES : "asset_code"
CURRENCIES ||--o{ BILLING_LEDGER : "asset_code"
SERVICES ||--o{ SERVICE_ACCEPTED_CURRENCIES : has
SERVICES ||--o{ SERVICE_GROUP_MEMBERS : member_of
SERVICE_GROUPS ||--o{ SERVICE_GROUP_MEMBERS : contains
SERVICE_PROVIDERS ||--o{ PROVIDER_SERVICE_GROUPS : offers
SERVICE_PROVIDERS ||--o{ PROVIDER_SERVICE_OVERRIDES : sets
SERVICE_PROVIDERS ||--o{ RUNNER_OWNERS : owns
SERVICE_PROVIDERS ||--o{ PROVIDER_SERVICE_RUNNERS : routes
SERVICE_PROVIDERS ||--o{ PROVIDER_SERVICE_GROUP_RUNNERS : routes
RUNNERS ||--o{ RUNNER_OWNERS : owned_by
RUNNERS ||--o{ PROVIDER_SERVICE_RUNNERS : executes
RUNNERS ||--o{ PROVIDER_SERVICE_GROUP_RUNNERS : executes
SUBSCRIPTIONS ||--o{ SUBSCRIPTION_PROVIDERS : allow
SERVICE_PROVIDERS ||--o{ SUBSCRIPTION_PROVIDERS : allowed
REQUESTS }o--|| ACCOUNTS : by
REQUESTS }o--|| SUBSCRIPTIONS : under
REQUESTS }o--|| SERVICE_PROVIDERS : via
REQUESTS }o--|| SERVICES : for
REQUESTS }o--o{ RUNNERS : executed_by
BILLING_LEDGER }o--|| ACCOUNTS : charges
BILLING_LEDGER }o--o{ SERVICES : reference
BILLING_LEDGER }o--o{ SERVICE_PROVIDERS : reference
BILLING_LEDGER }o--o{ REQUESTS : reference
```
### 9.2 Request Flow (Happy Path)
```mermaid
sequenceDiagram
autonumber
participant AC as Account
participant API as Broker/API
participant PR as Provider
participant RU as Runner
participant DB as PostgreSQL
AC->>API: Submit request (subscription_id, service_id, payload, secret)
API->>DB: Validate subscription (active, provider allowed, spend limits)
DB-->>API: OK + effective pricing (resolve precedence)
API->>PR: Dispatch request (service, payload)
PR->>DB: Select runner (provider_service_runners / group runners)
PR->>RU: Start job (payload)
RU-->>PR: Job started (started_at)
PR->>DB: Update REQUESTS (status=running, started_at)
RU-->>PR: Job finished (duration, result)
PR->>DB: Update REQUESTS (status=succeeded, ended_at)
API->>DB: Insert BILLING_LEDGER (debit per effective price)
DB-->>API: Ledger entry id
API-->>AC: Return result + charge info
```
### 9.3 Pricing Resolution
```mermaid
flowchart TD
A[Input: provider_id, service_id, asset_code] --> B{Provider override exists for (service, asset_code)?}
B -- Yes --> P1[Use provider price/mode/max]
B -- No --> C{Service accepted currency override exists?}
C -- Yes --> P2[Use service currency price/mode]
C -- No --> P3[Use service defaults]
P1 --> OUT[Effective pricing]
P2 --> OUT
P3 --> OUT
```
---
## 10) Operational Notes
* **Secrets:** store a hash (e.g., `digest(secret,'sha256')`) rather than raw `secret`. Keep the original only client-side.
* **Limits enforcement:** before insert of a debit ledger entry, compute period window (hour/day/month UTC or tenant TZ) and enforce `SUM(amount) + new_amount ≤ limit_amount`.
* **Durations:** enforce `max_request_seconds` (effective) at orchestration and/or via DB trigger on `REQUESTS` when transitioning to `running/succeeded`.
* **Routing:** prefer `provider_service_runners` when a request targets a service directly; otherwise use the union of runners from `provider_service_group_runners` for the group.
* **Balances:** serve balance queries via the `account_balances` view or a materialized cache updated by triggers/jobs.
---
## 11) Example Effective Pricing Query (sketch)
```sql
-- Inputs: :provider_id, :service_id, :asset_code
WITH p AS (
SELECT price_override, billing_mode_override, max_request_seconds_override
FROM provider_service_overrides
WHERE provider_id = :provider_id
AND service_id = :service_id
AND (asset_code = :asset_code)
),
sac AS (
SELECT price_override, billing_mode_override
FROM service_accepted_currencies
WHERE service_id = :service_id AND asset_code = :asset_code
),
svc AS (
SELECT default_price AS price, default_billing_mode AS mode, max_request_seconds
FROM services WHERE id = :service_id
)
SELECT
COALESCE(p.price_override, sac.price_override, svc.price) AS effective_price,
COALESCE(p.billing_mode_override, sac.billing_mode_override, svc.mode) AS effective_mode,
COALESCE(p.max_request_seconds_override, svc.max_request_seconds) AS effective_max_seconds;
```
---
## 12) Indices (non-exhaustive)
* `services(default_currency)`
* `service_accepted_currencies(service_id)`
* `provider_service_overrides(service_id, provider_id)`
* `requests(account_id)`, `requests(provider_id)`, `requests(service_id)`
* `billing_ledger(account_id, asset_code)`
* `subscriptions(account_id) WHERE active`
---
## 13) Migration & Compatibility
* Prefer additive migrations (new columns/tables) to avoid downtime.
* Use `ENUM` via `CREATE TYPE`; when extending, plan for `ALTER TYPE ... ADD VALUE`.
* For high-write ledgers, consider partitioning `billing_ledger` by `created_at` (monthly) and indexing partitions.
---
## 14) Non-Goals
* Wallet custody and on-chain settlement are out of scope.
* SLA tracking and detailed observability (metrics/log schema) are not part of this spec.
---
## 15) Acceptance Criteria
* Can represent services, groups, and providers with currency-specific pricing.
* Can route requests to runners by service or group.
* Can authorize usage via subscriptions, enforce spend limits, and record charges.
* Can reconstruct balances and audit via append-only ledger.
---
**End of Spec**

View File

@@ -0,0 +1,225 @@
# Concept Note: Generic Billing & Tracking Framework
## 1) Purpose
The model is designed to support a **flexible, generic, and auditable** billing environment that can be applied across diverse services and providers — from compute time billing to per-request API usage, across multiple currencies, with dynamic provider-specific overrides.
It is **not tied to a single business domain** — the same framework can be used for:
* Cloud compute time (per second)
* API transactions (per request)
* Data transfer charges
* Managed service subscriptions
* Brokered third-party service reselling
---
## 2) Key Concepts
### 2.1 Accounts
An **account** represents an economic actor in the system — typically a customer or a service provider.
* Identified by a **public key** (for authentication & cryptographic signing).
* Every billing action traces back to an account.
---
### 2.2 Currencies & Asset Codes
The system supports **multiple currencies** (crypto or fiat) via **asset codes**.
* Asset codes identify the unit of billing (e.g. `USDC-ETH`, `EUR`, `LND`).
* Currencies are **decoupled from services** so you can add or remove supported assets at any time.
---
### 2.3 Services & Groups
* **Service** = a billable offering (e.g., "Speech-to-Text", "VM Hosting").
* Has a **billing mode** (`per_second` or `per_request`).
* Has a **default price** and **default currency**.
* Supports **multiple accepted currencies** with optional per-currency pricing overrides.
* Has execution constraints (e.g. `max_request_seconds`).
* Includes structured schemas for request payloads.
* **Service Group** = a logical grouping of services.
* Groups make it easy to **bundle related services** and manage them together.
* Providers can offer entire groups rather than individual services.
---
### 2.4 Service Providers
A **service provider** is an **account** that offers services or service groups.
They can:
* Override **pricing** for their offered services (per currency).
* Route requests to their own **runners** (execution agents).
* Manage multiple **service groups** under one provider identity.
---
### 2.5 Runners
A **runner** is an execution agent — a node, VM, or service endpoint that can fulfill requests.
* Identified by an **IPv6 address** (supports Mycelium or other overlay networks).
* Can be owned by one or multiple providers.
* Providers map **services/groups → runners** to define routing.
---
### 2.6 Subscriptions
A **subscription** is **the authorization mechanism** for usage and spending control:
* Links an **account** to a **service** or **service group**.
* Defines **spending limits** (amount, currency, period: hour/day/month).
* Restricts which **providers** are allowed to serve the subscription.
* Uses a **secret** chosen by the subscriber — providers use this to claim charges.
---
### 2.7 Requests
A **request** represents a single execution under a subscription:
* Tied to **account**, **subscription**, **provider**, **service**, and optionally **runner**.
* Has **status** (`pending`, `running`, `succeeded`, `failed`, `canceled`).
* Records start/end times for duration-based billing.
---
### 2.8 Billing Ledger
The **ledger** is **append-only** — the source of truth for all charges and credits.
* Each entry records:
* `amount` (positive = debit, negative = credit/refund)
* `asset_code`
* Links to `account`, `provider`, `service`, and/or `request`
* From the ledger, **balances** can be reconstructed at any time.
---
## 3) How Billing Works — Step by Step
### 3.1 Setup
1. **Define services** with default pricing & schemas.
2. **Define currencies** and accepted currencies for services.
3. **Group services** into service groups.
4. **Onboard providers** (accounts) and associate them with service groups.
5. **Assign runners** to services or groups for execution routing.
---
### 3.2 Subscription Creation
1. Customer **creates a subscription**:
* Chooses service or service group.
* Sets **spending limit** (amount, currency, period).
* Chooses **secret**.
* Selects **allowed providers**.
2. Subscription is stored in DB.
---
### 3.3 Request Execution
1. Customer sends a request to broker/API with:
* `subscription_id`
* Target `service_id`
* Payload + signature using account pubkey.
2. Broker:
* Validates **subscription active**.
* Validates **provider allowed**.
* Checks **spend limit** hasnt been exceeded for current period.
* Resolves **effective price** via:
1. Provider override (currency-specific)
2. Service accepted currency override
3. Service default
3. Broker selects **runner** from providers routing tables.
4. Runner executes request and returns result.
---
### 3.4 Billing Entry
1. When the request completes:
* If `per_second` mode → calculate `duration × rate`.
* If `per_request` mode → apply flat rate.
2. Broker **inserts ledger entry**:
* Debit from customer account.
* Credit to provider account (can be separate entries or aggregated).
3. Ledger is append-only — historical billing cannot be altered.
---
### 3.5 Balance & Tracking
* **Current balances** are a sum of all ledger entries per account+currency.
* Spend limits are enforced by **querying the ledger** for the current period before each charge.
* Audit trails are guaranteed via immutable ledger entries.
---
## 4) Why This is Generic & Reusable
This design **decouples**:
* **Service definition** from **provider pricing** → multiple providers can sell the same service at different rates.
* **Execution agents** (runners) from **service definitions** → easy scaling or outsourcing of execution.
* **Billing rules** (per-second vs per-request) from **subscription limits** → same service can be sold in different billing modes.
* **Currencies** from the service → enabling multi-asset billing without changing the service definition.
Because of these separations, you can:
* Reuse the model for **compute**, **APIs**, **storage**, **SaaS features**, etc.
* Plug in different **payment backends** (on-chain, centralized payment processor, prepaid balance).
* Use the same model for **internal cost allocation** or **external customer billing**.
---
## 5) Potential Extensions
* **Prepaid model**: enforce that ledger debits cant exceed balance.
* **On-chain settlement**: periodically export ledger entries to blockchain transactions.
* **Discount models**: percentage or fixed-amount discounts per subscription.
* **Usage analytics**: aggregate requests/billing by time period, provider, or service.
* **SLAs**: link billing adjustments to performance metrics in requests.
---
## 6) Conceptual Diagram — Billing Flow
```mermaid
sequenceDiagram
participant C as Customer Account
participant B as Broker/API
participant P as Provider
participant R as Runner
participant DB as Ledger DB
C->>B: Request(service, subscription, payload, secret)
B->>DB: Validate subscription & spend limit
DB-->>B: OK + effective pricing
B->>P: Forward request
P->>R: Execute request
R-->>P: Result + execution time
P->>B: Return result
B->>DB: Insert debit (customer) + credit (provider)
DB-->>B: Ledger updated
B-->>C: Return result + charge info
```

View File

@@ -0,0 +1,234 @@
-- Enable useful extensions (optional)
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for digests/hashes if you want
CREATE EXTENSION IF NOT EXISTS btree_gist; -- for exclusion/partial indexes
-- =========================
-- Core: Accounts & Currency
-- =========================
CREATE TABLE accounts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pubkey BYTEA NOT NULL UNIQUE,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (id >= 0)
);
CREATE TABLE currencies (
asset_code TEXT PRIMARY KEY, -- e.g. "USDC-ETH", "EUR", "LND"
name TEXT NOT NULL,
symbol TEXT, -- e.g. "$", "€"
decimals INT NOT NULL DEFAULT 2, -- how many decimal places
UNIQUE (name)
);
-- =========================
-- Services & Groups
-- =========================
CREATE TYPE billing_mode AS ENUM ('per_second', 'per_request');
CREATE TABLE services (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
default_billing_mode billing_mode NOT NULL,
default_price NUMERIC(38, 18) NOT NULL, -- default price in "unit currency" (see accepted currencies)
default_currency TEXT NOT NULL REFERENCES currencies(asset_code) ON UPDATE CASCADE,
max_request_seconds INTEGER, -- nullable means no cap
schema_heroscript TEXT,
schema_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (id >= 0),
CHECK (default_price >= 0),
CHECK (max_request_seconds IS NULL OR max_request_seconds > 0)
);
-- Accepted currencies for a service (subset + optional specific price per currency)
CREATE TABLE service_accepted_currencies (
service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
asset_code TEXT NOT NULL REFERENCES currencies(asset_code) ON UPDATE CASCADE,
price_override NUMERIC(38, 18), -- if set, overrides default_price for this currency
billing_mode_override billing_mode, -- if set, overrides default_billing_mode
PRIMARY KEY (service_id, asset_code),
CHECK (price_override IS NULL OR price_override >= 0)
);
CREATE TABLE service_groups (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (id >= 0)
);
CREATE TABLE service_group_members (
group_id BIGINT NOT NULL REFERENCES service_groups(id) ON DELETE CASCADE,
service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
PRIMARY KEY (group_id, service_id)
);
-- =========================
-- Providers, Runners, Routing
-- =========================
CREATE TABLE service_providers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- provider is an account
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (name),
CHECK (id >= 0)
);
-- Providers can offer groups (which imply their services)
CREATE TABLE provider_service_groups (
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES service_groups(id) ON DELETE CASCADE,
PRIMARY KEY (provider_id, group_id)
);
-- Providers may set per-service overrides (price/mode/max seconds) (optionally per currency)
CREATE TABLE provider_service_overrides (
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE CASCADE,
service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
asset_code TEXT REFERENCES currencies(asset_code) ON UPDATE CASCADE,
price_override NUMERIC(38, 18),
billing_mode_override billing_mode,
max_request_seconds_override INTEGER,
PRIMARY KEY (provider_id, service_id, asset_code),
CHECK (price_override IS NULL OR price_override >= 0),
CHECK (max_request_seconds_override IS NULL OR max_request_seconds_override > 0)
);
-- Runners
CREATE TABLE runners (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
address INET NOT NULL, -- IPv6 (INET supports both IPv4/IPv6; require v6 via CHECK below if you like)
name TEXT NOT NULL,
description TEXT,
pubkey BYTEA, -- optional
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (address),
CHECK (id >= 0),
CHECK (family(address) = 6) -- ensure IPv6
);
-- Runner ownership: a runner can be owned by multiple providers
CREATE TABLE runner_owners (
runner_id BIGINT NOT NULL REFERENCES runners(id) ON DELETE CASCADE,
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE CASCADE,
PRIMARY KEY (runner_id, provider_id)
);
-- Routing: link providers' services to specific runners
CREATE TABLE provider_service_runners (
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE CASCADE,
service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
runner_id BIGINT NOT NULL REFERENCES runners(id) ON DELETE CASCADE,
PRIMARY KEY (provider_id, service_id, runner_id)
);
-- Routing: link providers' service groups to runners
CREATE TABLE provider_service_group_runners (
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES service_groups(id) ON DELETE CASCADE,
runner_id BIGINT NOT NULL REFERENCES runners(id) ON DELETE CASCADE,
PRIMARY KEY (provider_id, group_id, runner_id)
);
-- =========================
-- Subscriptions & Spend Control
-- =========================
CREATE TYPE spend_period AS ENUM ('hour', 'day', 'month');
-- A subscription ties an account to a specific service OR a service group, with spend limits and allowed providers
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
service_id BIGINT REFERENCES services(id) ON DELETE CASCADE,
group_id BIGINT REFERENCES service_groups(id) ON DELETE CASCADE,
secret BYTEA NOT NULL, -- caller-chosen secret (consider storing a hash instead)
subscription_data JSONB, -- arbitrary client-supplied info
limit_amount NUMERIC(38, 18), -- allowed spend in the selected currency per period
limit_currency TEXT REFERENCES currencies(asset_code) ON UPDATE CASCADE,
limit_period spend_period, -- period for the limit
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensure exactly one of service_id or group_id
CHECK ( (service_id IS NOT NULL) <> (group_id IS NOT NULL) ),
CHECK (limit_amount IS NULL OR limit_amount >= 0),
CHECK (id >= 0)
);
-- Providers that are allowed to serve under a subscription
CREATE TABLE subscription_providers (
subscription_id BIGINT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE CASCADE,
PRIMARY KEY (subscription_id, provider_id)
);
-- =========================
-- Usage, Requests & Billing
-- =========================
-- A request lifecycle record (optional but useful for auditing and max duration enforcement)
CREATE TYPE request_status AS ENUM ('pending', 'running', 'succeeded', 'failed', 'canceled');
CREATE TABLE requests (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
subscription_id BIGINT NOT NULL REFERENCES subscriptions(id) ON DELETE RESTRICT,
provider_id BIGINT NOT NULL REFERENCES service_providers(id) ON DELETE RESTRICT,
service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
runner_id BIGINT REFERENCES runners(id) ON DELETE SET NULL,
request_schema JSONB, -- concrete task payload (conforms to schema_json/heroscript)
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
status request_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (id >= 0),
CHECK (ended_at IS NULL OR started_at IS NULL OR ended_at >= started_at)
);
-- Billing ledger (debits/credits). Positive amount = debit to account (charge). Negative = credit/refund.
CREATE TYPE ledger_entry_type AS ENUM ('debit', 'credit', 'adjustment');
CREATE TABLE billing_ledger (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider_id BIGINT REFERENCES service_providers(id) ON DELETE SET NULL,
service_id BIGINT REFERENCES services(id) ON DELETE SET NULL,
request_id BIGINT REFERENCES requests(id) ON DELETE SET NULL,
amount NUMERIC(38, 18) NOT NULL, -- positive for debit, negative for credit
asset_code TEXT NOT NULL REFERENCES currencies(asset_code) ON UPDATE CASCADE,
entry_type ledger_entry_type NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (id >= 0)
);
-- Optional: running balances per account/currency (materialized view or real-time view)
-- This is a plain view; for performance, you might maintain a cached table.
CREATE VIEW account_balances AS
SELECT
account_id,
asset_code,
SUM(amount) AS balance
FROM billing_ledger
GROUP BY account_id, asset_code;
-- =========================
-- Helpful Indexes
-- =========================
CREATE INDEX idx_services_default_currency ON services(default_currency);
CREATE INDEX idx_service_accepted_currencies_service ON service_accepted_currencies(service_id);
CREATE INDEX idx_provider_overrides_service ON provider_service_overrides(service_id);
CREATE INDEX idx_requests_account ON requests(account_id);
CREATE INDEX idx_requests_provider ON requests(provider_id);
CREATE INDEX idx_requests_service ON requests(service_id);
CREATE INDEX idx_billing_account_currency ON billing_ledger(account_id, asset_code);
CREATE INDEX idx_subscriptions_account_active ON subscriptions(account_id) WHERE active;

View File

@@ -0,0 +1,266 @@
# Billing Logic — Whiteboard Version (for Devs)
## 1) Inputs You Always Need
* `account_id`, `subscription_id`
* `service_id` (or group → resolved to a service at dispatch)
* `provider_id`, `asset_code`
* `payload` (validated against service schema)
* (Optional) `runner_id`
* Idempotency key for the request (client-provided)
---
## 2) Gatekeeping (Hard Checks)
1. **Subscription**
* Must be `active`.
* Must target **exactly one** of {service, group}.
* If group: ensure `service_id` is a member.
2. **Provider Allowlist**
* If `subscription_providers` exists → `provider_id` must be listed.
3. **Spend Limit** (if set)
* Compute window by `limit_period` (`hour`/`day`/`month`, UTC unless tenant TZ).
* Current period spend = `SUM(ledger.amount WHERE account & currency & period)`.
* `current_spend + estimated_charge ≤ limit_amount`.
4. **Max Duration** (effective; see §3):
* If billing mode is `per_second`, reject if requested/max exceeds effective cap.
---
## 3) Effective Pricing (Single Resolution Function)
Inputs: `provider_id`, `service_id`, `asset_code`
Precedence:
1. `provider_service_overrides` for `(service_id, asset_code)`
2. `service_accepted_currencies` for `(service_id, asset_code)`
3. `services` defaults
Outputs:
* `effective_billing_mode ∈ {per_request, per_second}`
* `effective_price` (NUMERIC)
* `effective_max_request_seconds` (nullable)
---
## 4) Request Lifecycle (States)
* `pending``running` → (`succeeded` | `failed` | `canceled`)
* Timestamps: set `started_at` on `running`, `ended_at` on terminal states.
* Enforce `ended_at ≥ started_at` and `duration ≤ effective_max_request_seconds` (if set).
---
## 5) Charging Rules
### A) Per Request
```
charge = effective_price
```
### B) Per Second
```
duration_seconds = ceil(extract(epoch from (ended_at - started_at)))
charge = duration_seconds * effective_price
```
* Cap with `effective_max_request_seconds` if present.
* If ended early/failed before `started_at`: charge = 0.
---
## 6) Idempotency & Atomicity
* **Idempotency key** per `(account_id, subscription_id, provider_id, service_id, request_external_id)`; store on `requests` and enforce unique index.
* **Single transaction** to:
1. finalize `REQUESTS` status + timestamps,
2. insert **one** debit entry into `billing_ledger`.
* Never mutate ledger entries; use compensating **credit** entries for adjustments/refunds.
---
## 7) Spend-Limit Enforcement (Before Charging)
Pseudocode (SQL-ish):
```sql
WITH window AS (
SELECT tsrange(period_start(:limit_period), period_end(:limit_period)) AS w
),
spent AS (
SELECT COALESCE(SUM(amount), 0) AS total
FROM billing_ledger, window
WHERE account_id = :account_id
AND asset_code = :asset_code
AND created_at <@ (SELECT w FROM window)
),
check AS (
SELECT (spent.total + :estimated_charge) <= :limit_amount AS ok FROM spent
)
SELECT ok FROM check;
```
* If not ok → reject before dispatch, or allow but **set hard cap** on max seconds and auto-stop at limit.
---
## 8) Suggested DB Operations (Happy Path)
1. **Create request**
```sql
INSERT INTO requests (...)
VALUES (...)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
```
2. **Start execution**
```sql
UPDATE requests
SET status='running', started_at=now()
WHERE id=:id AND status='pending';
```
3. **Finish & bill** (single transaction)
```sql
BEGIN;
-- lock for update to avoid double-billing
UPDATE requests
SET status=:final_status, ended_at=now()
WHERE id=:id AND status='running'
RETURNING started_at, ended_at;
-- compute charge in app (see §5), re-check spend window here
INSERT INTO billing_ledger (
account_id, provider_id, service_id, request_id,
amount, asset_code, entry_type, description
) VALUES (
:account_id, :provider_id, :service_id, :id,
:charge, :asset_code, 'debit', :desc
);
COMMIT;
```
---
## 9) Balances & Reporting
* **Current balance** = `SUM(billing_ledger.amount) GROUP BY account_id, asset_code`.
* Keep a **view** or **materialized view**; refresh asynchronously if needed.
* Never rely on cached balance for hard checks — re-check within the billing transaction if **prepaid** semantics are required.
---
## 10) Error & Edge Rules
* If runner fails before `running` → no charge.
* If runner starts, then fails:
* **per\_second**: bill actual seconds (can be 0).
* **per\_request**: default is **no charge** unless policy says otherwise; if charging partials, document it.
* Partial refunds/adjustments → insert **negative** ledger entries (type `credit`/`adjustment`) tied to the original `request_id`.
---
## 11) Minimal Pricing Resolver (Sketch)
```sql
WITH p AS (
SELECT price_override AS price,
billing_mode_override AS mode,
max_request_seconds_override AS maxsec
FROM provider_service_overrides
WHERE provider_id = :pid AND service_id = :sid AND asset_code = :asset
LIMIT 1
),
sac AS (
SELECT price_override AS price,
billing_mode_override AS mode
FROM service_accepted_currencies
WHERE service_id = :sid AND asset_code = :asset
LIMIT 1
),
svc AS (
SELECT default_price AS price,
default_billing_mode AS mode,
max_request_seconds AS maxsec
FROM services WHERE id = :sid
)
SELECT
COALESCE(p.price, sac.price, svc.price) AS price,
COALESCE(p.mode, sac.mode, svc.mode) AS mode,
COALESCE(p.maxsec, svc.maxsec) AS max_seconds;
```
---
## 12) Mermaid — Decision Trees
### Pricing & Duration
```mermaid
flowchart TD
A[provider_id, service_id, asset_code] --> B{Provider override exists?}
B -- yes --> P[Use provider price/mode/max]
B -- no --> C{Service currency override?}
C -- yes --> S[Use service currency price/mode]
C -- no --> D[Use service defaults]
P --> OUT[effective price/mode/max]
S --> OUT
D --> OUT
```
### Spend Check & Charge
```mermaid
flowchart TD
S[Has subscription limit?] -->|No| D1[Dispatch]
S -->|Yes| C{current_spend + est_charge <= limit?}
C -->|No| REJ[Reject or cap duration]
C -->|Yes| D1[Dispatch]
D1 --> RUN[Run request]
RUN --> DONE[Finalize + insert ledger]
```
---
## 13) Security Posture
* Store **hash of subscription secret**; compare hash on use.
* Sign client requests with **account pubkey**; verify before dispatch.
* Limit **request schema** to validated fields; reject unknowns.
* Enforce **IPv6** for runners where required.
---
## 14) What To Implement First
1. Pricing resolver (single function).
2. Spend-window checker (single query).
3. Request lifecycle + idempotency.
4. Ledger write (append-only) + balances view.
Everything else layers on top.
---
If you want, I can turn this into a small **README.md** with code blocks you can paste into the repo (plus a couple of SQL functions and example tests).

View File

@@ -24,16 +24,6 @@ pub enum CurrencyType {
custom
}
pub struct Price {
pub mut:
base_amount f64 // Using f64 for Decimal
base_currency string
display_currency string
display_amount f64 // Using f64 for Decimal
formatted_display string
conversion_rate f64 // Using f64 for Decimal
conversion_timestamp u64 // Unix timestamp
}
pub struct MarketplaceCurrencyConfig {
pub mut: