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>
3.7 KiB
Billing — Usage Metering (ByteTinker–Bold 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 (dayis UTC).device_status_log(3-day) anddevice_telemetry(~24h) cannot back a billing month, so this is separate.- Accumulated incrementally off the heartbeat tick from the live connection map
(
services/heartbeat.jsdeviceConnections— the same sourcedevices_connecteduses), 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.