6.7 KiB
6.7 KiB
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)
- Subscription
- Must be
active
. - Must target exactly one of {service, group}.
- If group: ensure
service_id
is a member.
- Provider Allowlist
- If
subscription_providers
exists →provider_id
must be listed.
- 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
.
- 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:
provider_service_overrides
for(service_id, asset_code)
service_accepted_currencies
for(service_id, asset_code)
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
onrunning
,ended_at
on terminal states. - Enforce
ended_at ≥ started_at
andduration ≤ 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 onrequests
and enforce unique index. -
Single transaction to:
- finalize
REQUESTS
status + timestamps, - insert one debit entry into
billing_ledger
.
- finalize
-
Never mutate ledger entries; use compensating credit entries for adjustments/refunds.
7) Spend-Limit Enforcement (Before Charging)
Pseudocode (SQL-ish):
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)
- Create request
INSERT INTO requests (...)
VALUES (...)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
- Start execution
UPDATE requests
SET status='running', started_at=now()
WHERE id=:id AND status='pending';
- Finish & bill (single transaction)
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 originalrequest_id
.
11) Minimal Pricing Resolver (Sketch)
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
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
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
- Pricing resolver (single function).
- Spend-window checker (single query).
- Request lifecycle + idempotency.
- 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).