screentinker/docs/billing.md
ScreenTinker 385eda3cb1 feat(#146): owner-only CLI to mint billing:read tokens (scripts/mint-billing-token.js)
The billing:read scope + dual-path gate were built but there was no way to MINT a token
(and it must NOT go in the workspace-scoped, self-service API-Tokens UI). Adds a server-side,
owner-only CLI — no new UI, no network endpoint. Owner-only BY CONSTRUCTION: it's a
host-side script, so filesystem/shell access = the platform owner.

- server/lib/billing-token.js (testable): mintBillingToken/revokeBillingToken/
  listBillingTokens. Reuses the EXACT existing token path — same secret (st_ + 32 bytes
  base64url), same SHA-256 hashing (hashToken), same api_tokens columns — no second format.
  Resolves the platform OWNER (oldest platform_admin/superadmin; #14 collapsed superadmin ->
  platform_admin so that's the top tier) and binds to their workspace. api_tokens.user_id +
  workspace_id are BOTH NOT NULL (no platform-level token exists); the workspace binding is
  VESTIGIAL for billing (billing:read is off-ladder -> can't reach any workspace router;
  billing is platform-global), documented in-file rather than loosening NOT NULL pre-release.
- scripts/mint-billing-token.js: thin CLI wrapper. --name mints and prints the secret ONCE
  (+ id, + "run as owner on host" warning), --list, --revoke <id> (soft revoke, mirrors the
  dashboard DELETE).

Tests (4, test/billing-token-mint.test.js): minted row is scope EXACTLY billing:read with a
matching SHA-256 hash and no read/write/full/agency scope; the token reads GET
/api/billing/usage (200) but is refused on /api/devices (403) and /api/admin (401) — scope
isolation; revocation -> 401; mint requires a name; revoke refuses a non-billing id. CLI
smoked live (mint/list/revoke). Suite 310/310.

SPEC-vs-REALITY (again): spec said bcrypt + JSON `scopes`; this codebase uses SHA-256 + a
single `scope` TEXT column. Built to the real system.

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

4.2 KiB
Raw Permalink Blame History

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}] }.

Minting a billing:read token — owner only: billing tokens are minted server-side by the platform owner via node scripts/mint-billing-token.js --name "<label>" (printed ONCE; --list / --revoke <id> to manage). They are intentionally NOT in the workspace API-Tokens UI (that surface is workspace-scoped and self-service; a billing token grants platform-wide billing-read). The token authorizes ONLY GET /api/billing/usage — off the read/write/full ladder, refused everywhere else.

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 backfilldevice_status_log only holds 3 days). The first FULL, clean billing month is the first whole calendar month after beta7 is deployed.