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

77 lines
4.2 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}] }`.
**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 backfill**`device_status_log` only holds 3 days). **The first FULL,
clean billing month is the first whole calendar month after beta7 is deployed.**