Add NATS account-balance SoT and statement review for customers, CS, and sales
Some checks are pending
offline / test (push) Waiting to run

Internal billing now uses verae.billing.* request-reply and pubs. zappier-account-balance tracks prepaid, credits, usage, and payments. Portal, admin, CS, and sales all review the same statement. Independent Forgejo repos stay split via push-module-repos.
This commit is contained in:
George Lambert 2026-09-11 15:35:37 -04:00
parent a1a5b957fd
commit ac38676645
135 changed files with 3078 additions and 130 deletions

View file

@ -0,0 +1,89 @@
/**
* Source of truth for prepaid balances, CS credits, usage, and payments.
*/
export class AccountBooks {
constructor() {
/** @type {Record<string, number>} */
this.prepaid = {};
this.credits = [];
this.usage = [];
this.payments = [];
}
prepaidCents(customerId) {
return this.prepaid[customerId] ?? 0;
}
adjust({ customerId, cents, reason, agent, kind = 'credit' }) {
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) {
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,
prepaidCents: this.prepaidCents(customerId),
credits: match(this.credits),
usage: match(this.usage),
payments: match(this.payments),
};
}
}
export function handle(subject, payload, books) {
const p = payload || {};
if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) {
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}`);
}