screentinker/docs/billing.md
ScreenTinker 677b17028e feat(#146): billing:read scoped token — dual-path auth for the Usage Report (Option C)
Least-privilege way to read GET /api/billing/usage without requiring platform admin.
Additive + isolated: reuses the existing api_tokens scope system (the off-ladder 'agency'
scope is the precedent) and does NOT touch the shared role/permission checks other
endpoints rely on.

- New off-ladder scope 'billing:read' (routes/tokens.js SCOPES). Like 'agency' it is NOT
  on the read<write<full ladder, so tokenScopeGate rejects a billing token on every
  PUBLIC_ROUTER and JWT-only routers reject any st_ token -> the scope grants billing-read
  and NOTHING else.
- DUAL-PATH gate requireBillingRead (middleware/apiToken.js), written as an EXPLICIT OR:
  authorize if (billing:read token) OR (platform-admin session). Admins keep read access
  but are NOT required to; the token path doesn't lock out admins or vice versa. Billing
  route now mounted with bearerAuth (token OR JWT front door) + requireBillingRead (was
  requireAuth + requirePlatformAdmin).
- MINTING is platform-admin only (stricter than read/write/full/agency, which any
  workspace member may mint) since a billing:read token grants GLOBAL billing-read. Note:
  no finer "owner" tier exists here (#14 collapsed superadmin->platform_admin), so
  PLATFORM_ROLES is the top level required.

Tests (5, test/billing-authz.test.js): dual-path positive (token AND admin session both
200) + negative (user 403 / anon 401); scope isolation (billing token 403 on /api/devices,
401 on /api/admin; read token 200 on devices but 403 on billing); minting owner-only
(user + ordinary-admin 403, platform-admin 201); revocation -> 401. Existing token
firewall/partition suite (api.test.js) + billing-endpoint tests unchanged & green. Reused
the exact SHA-256 token-verification path (no bcrypt/new mechanism). Suite 306/306.

NOTE: spec described bcrypt + JSON `scopes` + an analytics:read precedent; this codebase
actually uses SHA-256 + a single `scope` TEXT column + 'agency' as the off-ladder
precedent. Implemented faithfully to the real system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:16:21 -05:00

70 lines
3.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Billing — Usage Metering (ByteTinkerBold Media distribution agreement)
**This is the contractual SYSTEM-OF-RECORD for invoicing (agreement §4.1/§4.2).** The math
below is implemented exactly in `server/lib/billing.js`; the defaults in `config.billing`
ARE the agreement — change them only if the contract changes.
## The formula
- **Provisioned Screen** — any device registered/provisioned. Provisioning alone is **not**
billed.
- **Active Screen-Day (ASD)** — per device, per calendar day:
```
ASD = min(1.0, online_seconds_that_day / (BILLING_HOURS_PER_DAY * 3600)) # denom = 28800 (8h)
```
Online ≥ 8h → 1.0; 4h → 0.5; offline → 0. Time beyond 8h does **not** increase it.
- **Billable Screens** — per billing month:
```
BillableScreens = round( Σ ASD (all devices, all calendar days in month) / days_in_month )
```
i.e. the average number of screens active during a standard 8-hour day, **rounded half up**.
- **Tier** — FLAT (not marginal): the single rate for the month's total Billable Screens.
| Billable Screens | Rate / screen / month |
|---|---|
| 1 499 | $1.50 |
| 500 999 | $1.25 |
| 1000 or more | $1.00 |
- **Cost (USD)** = BillableScreens × applicable tier rate.
Single **global** rate card for now; per-tenant rate cards are a future concern (would key
the rate table by workspace/org). All values are config-driven: `BILLING_HOURS_PER_DAY`,
`BILLING_RATE_TABLE` (JSON override), `BILLING_USAGE_RETENTION_DAYS`.
## Data foundation
- **`device_usage_daily(device_id, day 'YYYY-MM-DD', online_seconds)`** — a durable daily
rollup, one tiny row per device per calendar day (`day` is **UTC**). `device_status_log`
(3-day) and `device_telemetry` (~24h) cannot back a billing month, so this is separate.
- **Accumulated incrementally** off the heartbeat tick from the **live connection map**
(`services/heartbeat.js` `deviceConnections` — the same source `devices_connected` uses),
never reconstructed from logs. Each tick credits every connected device's today-row with
the elapsed seconds (`online_seconds = min(86400, online_seconds + credit)`), chunked and
transactional so it never blocks the event loop. Per-tick credit is capped
(`BILLING_ACCRUAL_CAP_SECONDS`) so a stalled loop / restart gap can't inject a bogus credit.
- **Retention** ~400 days, pruned via the chunked-prune helper (`pruneUsageDaily`) so it can
never bloat-then-freeze.
## API (admin-only, standalone route)
`GET /api/billing/usage?month=YYYY-MM` (default: current month) — readable via a
**`billing:read` scoped API token** (owner/platform-admin-minted, revocable, grants
billing-read ONLY) **OR** a platform-admin session; a `billing:read` token is the intended
consumer (tooling / invoice-time pulls / §4.2 verification) and cannot reach any other
endpoint. Mounted separately from `/api/status` (billing is revenue data and a heavier
aggregate; it must not touch the hot status path). Reads the rollup only. Returns:
`{ month, days_in_month, days_elapsed, provisioned_screens, billable_screens,
billable_screens_final?, is_final, tier, rate_usd, cost_usd, daily:[{day, active_screen_days}] }`.
**Month-to-date rule:** for the current month the average is computed over **completed
calendar days only** — today accrues live and appears in `daily` but is excluded from the
running average until it completes, so a partial today doesn't drag the estimate.
`billable_screens_final` and `is_final:true` appear only once the month is complete.
## First-full-month caveat
Metering starts accumulating **at deploy**. The first partial calendar month is incomplete
(and there is **no backfill**`device_status_log` only holds 3 days). **The first FULL,
clean billing month is the first whole calendar month after beta7 is deployed.**