/** * Billing + immutable-log handlers. * * Both read the per-instance billing ledger (`_emdash_usage`), which the parent * that hosts this instance populates over the Cloudflare D1 API (cost-plus * charges for real running cost, plus credit top-ups as negative rows). The * running balance is `purchased - spent`. * * The credit price book, markup and enforcement flag are pushed into the * instance's `options` (`credits:*`) by the parent; the `/billing` screen shows * them read-only. `billing:*` options carry where a top-up should be sent (the * parent's checkout endpoint + this instance's id in the parent's registry). */ import type { Kysely } from "kysely"; import { AuditRepository } from "../../database/repositories/audit.js"; import { OptionsRepository } from "../../database/repositories/options.js"; import { UsageRepository, type BillingSummary, type UsageEntry } from "../../database/repositories/usage.js"; import type { Database } from "../../database/types.js"; import type { ApiResult } from "../types.js"; /** Credit configuration the parent pushes into this instance. */ export interface CreditsConfig { /** Per-unit charge (micro-dollars) by metric key — informational display. */ prices: Record; /** Cost-plus multiplier applied to provider cost (e.g. 2 = 100% markup). */ markup: number; /** When true, the instance is metered and suspended once credits run out. */ enforce: boolean; } /** Where a credit top-up is sent (the hosting parent's checkout). */ export interface TopupTarget { /** Parent checkout endpoint, e.g. https://premium-cms.com/…/billing/checkout. */ url: string | null; /** This instance's project id in the parent's registry. */ projectId: string | null; /** ISO currency, default USD. */ currency: string; /** Suggested top-up amounts in whole currency units (dollars). */ presets: number[]; } export interface BillingResponse extends BillingSummary { credits: CreditsConfig; topup: TopupTarget; } const DEFAULT_PRESETS = [10, 25, 50, 100]; export async function handleBillingSummary( db: Kysely, ): Promise> { const usage = new UsageRepository(db); const options = new OptionsRepository(db); const [summary, prices, markup, enforce, parentUrl, projectId, currency, presets] = await Promise.all([ usage.summary(), options.getOrDefault>("credits:prices", {}), options.getOrDefault("credits:markup", 2), options.getOrDefault("credits:enforce", false), options.getOrDefault("billing:parent_url", null), options.getOrDefault("billing:project_id", null), options.getOrDefault("billing:currency", "USD"), options.getOrDefault("billing:presets", DEFAULT_PRESETS), ]); return { success: true, data: { ...summary, credits: { prices: prices ?? {}, markup: Number(markup) || 2, enforce: Boolean(enforce) }, topup: { url: parentUrl, projectId, currency: currency || "USD", presets: Array.isArray(presets) && presets.length ? presets : DEFAULT_PRESETS, }, }, }; } /** A unified immutable-log row: either a billing ledger row or an audit event. */ export interface LogEntry { id: string; ts: string; /** "billing" for ledger rows, "audit" for admin-action rows. */ channel: "billing" | "audit"; /** Ledger key or audit action. */ key: string; kind: string; actorId: string | null; /** Micro-dollar charge for billing rows; 0 for audit rows. */ chargeMicros: number; quantity: number; details: Record | null; } function usageToLog(e: UsageEntry): LogEntry { return { id: e.id, ts: e.ts, channel: "billing", key: e.key, kind: e.kind, actorId: e.actorId, chargeMicros: e.chargeMicros, quantity: e.quantity, details: e.meta, }; } /** * Immutable log — "logs everything": the billing ledger merged with the * admin audit trail, newest first. `before` is an exclusive ISO-timestamp * cursor spanning both channels. */ export async function handleImmutableLog( db: Kysely, opts: { limit?: number; before?: string } = {}, ): Promise> { const limit = Math.min(Math.max(opts.limit ?? 100, 1), 300); const usage = new UsageRepository(db); const audit = new AuditRepository(db); // Over-fetch both channels by `limit`, merge, then trim — so the merged // page is correct even when one channel dominates a time window. const [ledger, auditPage] = await Promise.all([ usage.list({ limit, before: opts.before }), audit.findMany({ limit, until: opts.before }), ]); const auditLogs: LogEntry[] = auditPage.items.map((a) => ({ id: a.id, ts: a.timestamp, channel: "audit", key: a.action, kind: a.status ?? "event", actorId: a.actorId, chargeMicros: 0, quantity: 0, details: { ...(a.details ?? {}), ...(a.resourceType ? { resourceType: a.resourceType } : {}), ...(a.resourceId ? { resourceId: a.resourceId } : {}), ...(a.actorIp ? { actorIp: a.actorIp } : {}), }, })); const merged = [...ledger.entries.map(usageToLog), ...auditLogs] .sort((x, y) => (x.ts < y.ts ? 1 : x.ts > y.ts ? -1 : 0)) .slice(0, limit); const nextCursor = merged.length === limit ? merged[merged.length - 1].ts : null; return { success: true, data: { entries: merged, nextCursor } }; }