screentinker/server/lib/billing.js
ScreenTinker 977407ce99 feat(#146): usage metering + admin-gated Billable Screens report (contract system-of-record)
Implements the ByteTinker-Bold distribution-agreement billing math and surfaces it on a
standalone admin-only route. No UI (the API figure is the deliverable). Server-side only.

Contract math (lib/billing.js, config-driven; defaults ARE the agreement):
- ASD (per device/day) = min(1.0, online_seconds / (hours*3600))   # 28800 default
- BillableScreens (per month) = round-half-up( Sum ASD / days_in_month )
- Flat tier (not marginal): 1-499 $1.50 / 500-999 $1.25 / 1000+ $1.00; cost = screens*rate.
Single global rate card for now (per-tenant is a future concern; noted in code).

Data foundation:
- New durable rollup device_usage_daily(device_id, day 'YYYY-MM-DD', online_seconds),
  index on day. status_log (3d) / telemetry (24h) can't back a billing month.
- Accumulated INCREMENTALLY off the heartbeat tick from the live connection map (same
  source as devices_connected) - never reconstructed from logs. Each tick credits every
  connected device's today-row (min(86400, +elapsed)), chunked + transactional (non-blocking);
  per-tick credit capped (accrualCapSeconds) as a stall/restart guard.
- Retention ~400d, pruned via chunked-prune (pruneUsageDaily in runMaintenance).

API: GET /api/billing/usage?month=YYYY-MM (default current), requirePlatformAdmin, mounted
SEPARATELY from /api/status (billing is revenue data + a heavier aggregate; must not touch
the hot status path). Reads the rollup only. MTD figure averages over COMPLETED days only
(today shown in `daily` but excluded until it completes); is_final + billable_screens_final
appear once the month completes.

Tests (12): ASD math; billable round-half-up; flat tier/cost boundaries; accumulator
(accrues by interval, caps at 86400/day, disconnected doesn't accrue); report MTD-excludes-
today + final-month is_final; retention prune; endpoint authz (admin 200 / non-admin 403 /
anon 401) + billing absent from /api/status. Suite 301/301. First-full-month caveat +
formula in docs/billing.md.

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

134 lines
5.8 KiB
JavaScript
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.

'use strict';
// #146 BILLING — usage metering per the ByteTinkerBold Media distribution agreement.
// This module is the CONTRACTUAL SYSTEM-OF-RECORD math. It reads ONLY the durable daily
// rollup (device_usage_daily) — never raw logs — so the Usage Report is cheap.
//
// Contract definitions (see docs/billing.md):
// ASD (Active Screen-Day, per device per day) = min(1.0, online_seconds / (hours*3600))
// BillableScreens (per month) = round( Σ ASD over all devices & days / days_in_month )
// → the average number of screens active during a standard 8-hour day, round HALF UP.
// Tier is FLAT (not marginal): the single rate for the month's total BillableScreens.
// Cost = BillableScreens × tier rate.
const config = require('../config');
const { db } = require('../db/database');
// --- date helpers (UTC — deterministic, server-timezone-independent; the accumulator
// writes UTC day keys, and the report groups by the same keys, so they always align) ---
function utcDay(ms) { return new Date(ms).toISOString().slice(0, 10); } // YYYY-MM-DD
function utcMonth(ms) { return new Date(ms).toISOString().slice(0, 7); } // YYYY-MM
function daysInMonth(month) { const [y, m] = month.split('-').map(Number); return new Date(Date.UTC(y, m, 0)).getUTCDate(); }
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
// --- pure contract math (exported for unit tests) ---
// ASD for one device on one day.
function activeScreenDay(onlineSeconds) {
const denom = config.billing.hoursPerDay * 3600; // 28800 default
return Math.min(1.0, onlineSeconds / denom);
}
// Round half up. (Math.round already rounds .5 up for positive values; explicit for
// intent — "nearest whole screen, round half up" per the agreement.)
function roundHalfUp(x) { return Math.floor(x + 0.5); }
// BillableScreens = round( Σ ASD / days ). `days` is the CALENDAR-day denominator (see
// buildUsageReport for month-to-date vs final).
function billableScreens(sumAsd, days) {
if (days <= 0) return 0;
return roundHalfUp(sumAsd / days);
}
// The flat tier for a given screen count: the rate whose minScreens is the greatest ≤
// screens. Returns null below the lowest threshold (e.g. 0 screens → not billed).
function tierFor(screens) {
const table = [...config.billing.rateTable].sort((a, b) => a.minScreens - b.minScreens);
let chosen = null;
for (const t of table) if (screens >= t.minScreens) chosen = t;
return chosen;
}
// Human tier label from the rate table thresholds, e.g. "1-499", "500-999", "1000+".
function tierLabel(screens) {
const table = [...config.billing.rateTable].sort((a, b) => a.minScreens - b.minScreens);
const idx = table.reduce((acc, t, i) => (screens >= t.minScreens ? i : acc), -1);
if (idx < 0) return null;
const next = table[idx + 1];
return next ? `${table[idx].minScreens}-${next.minScreens - 1}` : `${table[idx].minScreens}+`;
}
function round2(x) { return Math.round(x * 100) / 100; }
// --- Usage Report (the API payload) ---
// Reads device_usage_daily only. `nowMs` is injectable for deterministic tests; the route
// passes Date.now().
//
// MONTH-TO-DATE RULE: for the CURRENT month the billable-screens average is computed over
// COMPLETED calendar days only — today accrues live and is shown in `daily` but is EXCLUDED
// from the running average until it completes, so a partial today doesn't drag the estimate
// down. For a past (final) month every day is complete, so the same formula yields the
// contractual Σ ASD / days_in_month.
const _asdByDay = db.prepare(
`SELECT day, SUM(MIN(1.0, online_seconds / CAST(? AS REAL))) AS asd
FROM device_usage_daily WHERE day BETWEEN ? AND ? GROUP BY day ORDER BY day`
);
const _distinctDevices = db.prepare(
'SELECT COUNT(DISTINCT device_id) AS c FROM device_usage_daily WHERE day BETWEEN ? AND ?'
);
function buildUsageReport(monthArg, nowMs = Date.now()) {
const month = monthArg || utcMonth(nowMs);
if (!MONTH_RE.test(month)) throw new Error('invalid month (expected YYYY-MM)');
const dim = daysInMonth(month);
const firstDay = `${month}-01`;
const lastDay = `${month}-${String(dim).padStart(2, '0')}`;
const todayStr = utcDay(nowMs);
const nowMonth = utcMonth(nowMs);
// Where are we relative to this month?
let isFinal, completedDays, daysElapsed;
if (month < nowMonth) { isFinal = true; completedDays = dim; daysElapsed = dim; }
else if (month > nowMonth) { isFinal = false; completedDays = 0; daysElapsed = 0; }
else { isFinal = false; const todayDom = new Date(nowMs).getUTCDate(); completedDays = todayDom - 1; daysElapsed = todayDom; }
const denom = config.billing.hoursPerDay * 3600;
const rows = _asdByDay.all(denom, firstDay, lastDay); // [{day, asd}] per day WITH data
const provisioned = _distinctDevices.get(firstDay, lastDay).c;
// Sum ASD over COMPLETED calendar days only (day strictly before today). For a past
// month todayStr is in a later month, so every row qualifies.
let sumAsdCompleted = 0;
const daily = [];
for (const r of rows) {
daily.push({ day: r.day, active_screen_days: Math.round(r.asd * 1000) / 1000 });
if (r.day < todayStr) sumAsdCompleted += r.asd;
}
const billable = billableScreens(sumAsdCompleted, completedDays);
const tier = tierFor(billable);
const rate = tier ? tier.rate : 0;
const out = {
month,
days_in_month: dim,
days_elapsed: daysElapsed,
provisioned_screens: provisioned,
billable_screens: billable,
is_final: isFinal,
tier: tierLabel(billable),
rate_usd: rate,
cost_usd: round2(billable * rate),
daily,
};
// Only present once the month is complete (agreement §4.1 finalized figure).
if (isFinal) out.billable_screens_final = billable;
return out;
}
module.exports = {
activeScreenDay, roundHalfUp, billableScreens, tierFor, tierLabel,
buildUsageReport, utcDay, utcMonth, daysInMonth,
};