267 lines
6.7 KiB
Markdown
267 lines
6.7 KiB
Markdown
# 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).
|