...
This commit is contained in:
266
specs/billingmanager_research/summary.md
Normal file
266
specs/billingmanager_research/summary.md
Normal 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).
|
Reference in New Issue
Block a user