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