/** * Source of truth for prepaid balances, CS credits, usage, and payments. */ export class AccountBooks { constructor() { /** @type {Record} */ this.prepaid = {}; /** @type {Record} */ this.veraeUserIds = {}; /** @type {Record} */ this.names = {}; this.credits = []; this.usage = []; this.payments = []; } remember(customerId, veraeUserId, name) { if (customerId && veraeUserId) this.veraeUserIds[customerId] = veraeUserId; if (customerId && name) this.names[customerId] = name; } prepaidCents(customerId) { return this.prepaid[customerId] ?? 0; } adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId, name }) { this.remember(customerId, veraeUserId, name); 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, entry.name); 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', }); } lookup(idOrName) { if (this.names[idOrName] || this.prepaid[idOrName] != null || this.veraeUserIds[idOrName]) { return this.statement(idOrName); } const want = String(idOrName || '').toLowerCase(); const hit = Object.entries(this.names).find(([, n]) => String(n).toLowerCase() === want); return this.statement(hit ? hit[0] : idOrName); } statement(customerId) { const match = (rows) => rows.filter((r) => r.customerId === customerId).slice(0, 100); return { customerId, name: this.names[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, names: this.names, 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.names = raw.names || {}; 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('customer.put')) { books.remember(p.customerId, p.veraeUserId, p.name); return books.lookup(p.customerId); } if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) { books.remember(p.customerId, p.veraeUserId, p.name); return books.lookup(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}`); }