master-zapier-plan-draft/packages/zappier-account-balance/src/books.js
George Lambert ddf772454b
Some checks are pending
offline / test (push) Waiting to run
Clean prepaid SoT, identity mailbox, public access planes, leaf policy, fleet spawn
Persist account-balance books; edge caches prepaid from books. Add zappier-identity, verae-nats-accounts, verae-jobs-events, verae-access-staff, zapier-decisions. Edge binds loopback; lan-134 stays off; HTTP services prefer local spawn.
2026-09-11 17:15:16 -04:00

119 lines
3.4 KiB
JavaScript

/**
* Source of truth for prepaid balances, CS credits, usage, and payments.
*/
export class AccountBooks {
constructor() {
/** @type {Record<string, number>} */
this.prepaid = {};
/** @type {Record<string, string>} */
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}`);
}