/** * Source of truth for prepaid balances, CS credits, usage, and payments. */ export class AccountBooks { constructor() { /** @type {Record} */ this.prepaid = {}; /** @type {Record} */ this.veraeUserIds = {}; this.credits = []; this.usage = []; this.payments = []; } remember(customerId, veraeUserId) { if (customerId && veraeUserId) this.veraeUserIds[customerId] = veraeUserId; } prepaidCents(customerId) { return this.prepaid[customerId] ?? 0; } adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId }) { this.remember(customerId, veraeUserId); const delta = Math.trunc(Number(cents) || 0); const next = this.prepaidCents(customerId) + delta; this.prepaid[customerId] = next; const row = { id: `adj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`, customerId, cents: delta, reason: reason || kind, agent: agent || 'system', kind, at: new Date().toISOString(), prepaidCents: next, }; if (kind === 'payment' || kind === 'reload') this.payments.unshift(row); else this.credits.unshift(row); return row; } recordUsage(entry) { this.remember(entry.customerId, entry.veraeUserId); const cents = Number(entry.cents) || 0; const customerId = entry.customerId; const next = this.prepaidCents(customerId) - cents; this.prepaid[customerId] = next; const row = { customerId, endpointId: entry.endpointId || 'unknown', cents, at: entry.at || new Date().toISOString(), prepaidCents: next, }; this.usage.unshift(row); return row; } recordPayment(entry) { return this.adjust({ customerId: entry.customerId, cents: Number(entry.cents) || 0, reason: entry.reason || 'payment', agent: entry.agent || 'payments', kind: 'payment', }); } statement(customerId) { const match = (rows) => rows.filter((r) => r.customerId === customerId).slice(0, 100); return { customerId, veraeUserId: this.veraeUserIds[customerId], prepaidCents: this.prepaidCents(customerId), credits: match(this.credits), usage: match(this.usage), payments: match(this.payments), }; } dump() { return { prepaid: this.prepaid, veraeUserIds: this.veraeUserIds, credits: this.credits, usage: this.usage, payments: this.payments, }; } load(raw) { if (!raw || typeof raw !== 'object') return this; this.prepaid = raw.prepaid || {}; this.veraeUserIds = raw.veraeUserIds || {}; this.credits = Array.isArray(raw.credits) ? raw.credits : []; this.usage = Array.isArray(raw.usage) ? raw.usage : []; this.payments = Array.isArray(raw.payments) ? raw.payments : []; return this; } } export function handle(subject, payload, books) { const p = payload || {}; if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) { books.remember(p.customerId, p.veraeUserId); return books.statement(p.customerId); } if (subject.endsWith('balance.adjust') || subject.endsWith('credit.applied')) { return books.adjust(p); } if (subject.endsWith('usage.recorded')) { return books.recordUsage(p); } if (subject.endsWith('payment.recorded')) { return books.recordPayment(p); } throw new Error(`unknown billing subject ${subject}`); }