Files
db/specs/billingmanager_research/summary.md
2025-08-21 17:26:40 +02:00

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)

  1. Subscription
  • Must be active.
  • Must target exactly one of {service, group}.
  • If group: ensure service_id is a member.
  1. Provider Allowlist
  • If subscription_providers exists → provider_id must be listed.
  1. 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.
  1. 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)

  • pendingrunning → (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):

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
INSERT INTO requests (...)
VALUES (...)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
  1. Start execution
UPDATE requests
SET status='running', started_at=now()
WHERE id=:id AND status='pending';
  1. 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 original request_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

  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).