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

@ -98,6 +98,7 @@ async function load() {
renderEndpoints();
renderTiers();
renderCustomers();
renderStatement();
renderInvoices();
renderReports();
renderSystem();
@ -277,7 +278,8 @@ function renderCustomers() {
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="billingType">${btOptions(c.billingType)}</select></td>
<td class="row-actions"><button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
<td class="row-actions"><button class="btn ghost" onclick="reviewCustomer('${c.id}')">Statement</button>
<button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
</tr>`,
)
.join('');
@ -308,6 +310,47 @@ async function saveCustomer(id) {
await load();
}
function renderStatement() {
const options = customerOptions(state.customers[0]?.id || '');
document.getElementById('statement').innerHTML = `
<h2>Customer statement</h2>
<p class="lede">Credits, prepaid balance, usage, and payments. Prefers NATS account-balance.</p>
<div class="card">
<div class="filterbar">
<label><span>Customer</span><select id="stmt-customer">${options}</select></label>
<button class="btn" onclick="reviewCustomer(document.getElementById('stmt-customer').value)">Load</button>
</div>
<div id="stmt-out"></div>
</div>`;
}
async function reviewCustomer(id) {
if (!id) return;
const st = await api(`/statement/${id}`);
const row = (list, cols) =>
(list || [])
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None.</td></tr>`;
const html = `
<p class="lede">${st.name || id} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
<div class="card"><h3>Credits</h3><table><thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
const out = document.getElementById('stmt-out');
if (out) out.innerHTML = html;
else {
document.getElementById('statement').innerHTML = `<h2>Customer statement</h2>${html}`;
document.querySelectorAll('nav button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'statement'));
document.querySelectorAll('main section').forEach((s) => (s.hidden = s.id !== 'statement'));
}
const sel = document.getElementById('stmt-customer');
if (sel) sel.value = id;
say(`Loaded statement for ${id}.`);
}
async function addCustomer() {
const name = document.getElementById('new-customer-name').value.trim();
const tierId = document.getElementById('new-customer-tier').value;

View file

@ -236,6 +236,7 @@
<button data-tab="endpoints" class="active">&nbsp; Rate card</button>
<button data-tab="tiers">&nbsp; Customer types</button>
<button data-tab="customers">&nbsp; Customers</button>
<button data-tab="statement">&nbsp; Statement</button>
<button data-tab="invoices">&nbsp; Invoices</button>
<button data-tab="reports">&nbsp; Reports</button>
<button data-tab="system">&nbsp; System</button>
@ -248,6 +249,7 @@
<section id="endpoints"></section>
<section id="tiers" hidden></section>
<section id="customers" hidden></section>
<section id="statement" hidden></section>
<section id="invoices" hidden></section>
<section id="reports" hidden></section>
<section id="system" hidden></section>

View file

@ -13,6 +13,7 @@
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-openapi-validator": "^5.3.0",
"nats": "^2.29.3",
"qrcode": "^1.5.4",
"stripe": "^16.0.0",
"swagger-ui-express": "^5.0.0",
@ -4397,6 +4398,19 @@
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/nats": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
"license": "Apache-2.0",
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -4420,6 +4434,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"license": "Apache-2.0",
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
@ -5821,6 +5847,12 @@
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View file

@ -14,6 +14,7 @@
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-openapi-validator": "^5.3.0",
"nats": "^2.29.3",
"qrcode": "^1.5.4",
"stripe": "^16.0.0",
"swagger-ui-express": "^5.0.0",

View file

@ -1,4 +1,4 @@
const state = { me: null, usage: null, invoices: [], pricing: null };
const state = { me: null, usage: null, invoices: [], pricing: null, statement: null };
const TOKEN_KEY = 'zappier-portal-token';
let authMode = 'login'; // 'login' | 'signup'
@ -118,8 +118,10 @@ async function load() {
state.usage = await api('/usage');
state.invoices = (await api('/invoices')).invoices;
state.pricing = await api('/pricing');
state.statement = await api('/statement');
renderDashboard();
renderInvoices();
renderStatement();
renderBilling();
renderSecurity();
renderDocs();
@ -200,6 +202,34 @@ async function viewInvoice(id) {
window.open(URL.createObjectURL(blob), '_blank');
}
/* ---------------- statement ---------------- */
function renderStatement() {
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
const row = (list, cols) =>
(list || [])
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None yet.</td></tr>`;
document.getElementById('statement').innerHTML = `
<h2>Statement</h2>
<p class="lede">Prepaid balance, customer-service credits, metered usage, and payments. ${st.source === 'nats' ? 'Live from account-balance over NATS.' : 'From this portals ledger.'}</p>
<div class="stat-grid">
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents || 0)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${st.credits?.length || 0}</div></div>
<div class="stat"><div class="k">Usage events</div><div class="v">${st.usage?.length || 0}</div></div>
<div class="stat"><div class="k">Payments</div><div class="v">${st.payments?.length || 0}</div></div>
</div>
<div class="card"><h3>Credits</h3><table>
<thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table>
<thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table>
<thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
}
/* ---------------- billing ---------------- */
function renderBilling() {

View file

@ -248,6 +248,7 @@
<nav>
<button data-tab="dashboard" class="active">&nbsp; Dashboard</button>
<button data-tab="invoices">&nbsp; Invoices</button>
<button data-tab="statement">&nbsp; Statement</button>
<button data-tab="billing">&nbsp; Billing</button>
<button data-tab="security">&nbsp; Security</button>
<button data-tab="docs">&nbsp; API &amp; pricing</button>
@ -258,6 +259,7 @@
<main>
<section id="dashboard"></section>
<section id="invoices" hidden></section>
<section id="statement" hidden></section>
<section id="billing" hidden></section>
<section id="security" hidden></section>
<section id="docs" hidden></section>

View file

@ -12,6 +12,8 @@ import { billingRows, toCsv, usageTrend } from './reports';
import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>();
@ -246,6 +248,14 @@ export function adminRouter(
agent: typeof agent === 'string' ? agent : 'admin',
});
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, rec);
void natsAdjust({
customerId,
cents: delta,
reason: rec.reason,
agent: rec.agent,
kind: 'credit',
});
res.status(201).json(rec);
});
@ -254,6 +264,31 @@ export function adminRouter(
res.json({ credits: credits.list(customerId) });
});
router.get('/statement/:id', async (req, res) => {
const customer = customers.list().find((c) => c.id === req.params.id);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
}
const fromNats = await natsStatement(customer.id);
if (fromNats) {
res.json({ ...fromNats, name: customer.name, tierId: customer.tierId, source: 'nats' });
return;
}
res.json({
...composeStatement({
customerId: customer.id,
prepaidCents: customer.balanceCents ?? 0,
credits: credits.list(customer.id),
usage: accounting.usage.listFor(customer.id),
invoices: accounting.invoices.list({ customerId: customer.id }),
}),
name: customer.name,
tierId: customer.tierId,
source: 'local',
});
});
router.get('/sales/quote/:id', (req, res) => {
const customer = customers.list().find((c) => c.id === req.params.id);
if (!customer) {

View file

@ -26,6 +26,9 @@ import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
import { PROJECT_ROOT } from './paths';
import { PaymentClient, portalRouter } from './portal';
import { proxyVerae } from './upstream';
import { CreditLedger } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
export interface StoredItem {
id: string;
@ -98,6 +101,9 @@ export function buildApp(deps: AppDeps = {}): {
},
};
const items: StoredItem[] = [];
const credits = new CreditLedger();
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) =>
natsPublish(BILLING_SUBJECTS.USAGE_RECORDED, { ...e, at: new Date().toISOString() });
const hashIndex = new Map<
string,
{ jobId: string; sha256: string; data?: string; timestamp: string }
@ -114,7 +120,7 @@ export function buildApp(deps: AppDeps = {}): {
'/admin/api',
adminLoginRouter(adminUsers),
adminAuth(),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits),
);
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
@ -140,6 +146,7 @@ export function buildApp(deps: AppDeps = {}): {
rateCard: () => pricingStore.getRateCard(),
payments,
qr,
credits,
}),
);
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
@ -158,16 +165,16 @@ export function buildApp(deps: AppDeps = {}): {
}),
);
app.get('/v1/status', meter('status', usage, pricing), (req, res) => {
app.get('/v1/status', meter('status', usage, pricing, onUsage), (req, res) => {
res.json({ status: 'ok', quote: res.locals.quote });
});
app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => {
app.post('/v1/transform', meter('transform', usage, pricing, onUsage), (req, res) => {
const text = String(req.body?.text ?? '');
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
});
app.post('/v1/timestamp', meter('timestamp', usage, pricing), async (req, res) => {
app.post('/v1/timestamp', meter('timestamp', usage, pricing, onUsage), async (req, res) => {
if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return;
const data = req.body?.data != null ? String(req.body.data) : '';
const sha256 =
@ -190,7 +197,7 @@ export function buildApp(deps: AppDeps = {}): {
res.status(202).json({ jobId, sha256, existing: false, timestamp });
});
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), async (req, res) => {
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing, onUsage), async (req, res) => {
if (await proxyVerae(req, res, `/zapier/v1/receipts/${req.params.jobId}`)) return;
const rec = jobIndex.get(String(req.params.jobId));
if (!rec) {
@ -208,7 +215,7 @@ export function buildApp(deps: AppDeps = {}): {
});
});
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), async (req, res) => {
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing, onUsage), async (req, res) => {
if (await proxyVerae(req, res, `/zapier/v1/hashes/${req.params.sha256}`)) return;
const sha256 = String(req.params.sha256 || '').toLowerCase();
const rec = hashIndex.get(sha256);
@ -219,7 +226,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ exists: true, ...rec });
});
app.post('/v1/add', meter('add', usage, pricing), (req, res) => {
app.post('/v1/add', meter('add', usage, pricing, onUsage), (req, res) => {
const number1 = Number(req.body?.number1);
const number2 = Number(req.body?.number2);
if (!Number.isFinite(number1) || !Number.isFinite(number2)) {
@ -229,7 +236,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ number1, number2, sum: number1 + number2, quote: res.locals.quote });
});
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing), (req, res) => {
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing, onUsage), (req, res) => {
const metadata = res.locals.parsedMetadata as Record<string, unknown>;
const files = (req.files as Express.Multer.File[]) ?? [];
const item: StoredItem = {
@ -243,7 +250,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ id: item.id, quote: res.locals.quote });
});
app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => {
app.get('/v1/storage', meter('storage-list', usage, pricing, onUsage), (req, res) => {
res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
});

View file

@ -0,0 +1,73 @@
/** Internal NATS billing bus. No-op when NATS_URL is unset (tests). */
export const BILLING_SUBJECTS = {
STATEMENT_GET: 'verae.billing.statement.get',
BALANCE_ADJUST: 'verae.billing.balance.adjust',
USAGE_RECORDED: 'verae.billing.usage.recorded',
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
CREDIT_APPLIED: 'verae.billing.credit.applied',
};
export type BillingStatement = {
customerId: string;
prepaidCents: number;
credits: unknown[];
usage: unknown[];
payments: unknown[];
};
type Nc = {
request: (s: string, d: Uint8Array, o: { timeout: number }) => Promise<{ data: Uint8Array }>;
publish: (s: string, d: Uint8Array) => void;
};
let ncPromise: Promise<Nc | null> | null = null;
async function nc(): Promise<Nc | null> {
const url = process.env.NATS_URL;
if (!url) return null;
if (!ncPromise) {
ncPromise = (async () => {
try {
const nats = await import('nats');
return (await nats.connect({ servers: url.split(','), name: 'zappier-edge' })) as unknown as Nc;
} catch {
return null;
}
})();
}
return ncPromise;
}
function encode(obj: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(obj));
}
function decode(buf: Uint8Array): unknown {
return JSON.parse(new TextDecoder().decode(buf) || '{}');
}
export async function natsStatement(customerId: string): Promise<BillingStatement | null> {
const c = await nc();
if (!c) return null;
const m = await c.request(BILLING_SUBJECTS.STATEMENT_GET, encode({ customerId }), { timeout: 2000 });
return decode(m.data) as BillingStatement;
}
export async function natsAdjust(payload: {
customerId: string;
cents: number;
reason: string;
agent: string;
kind?: string;
}): Promise<unknown | null> {
const c = await nc();
if (!c) return null;
const m = await c.request(BILLING_SUBJECTS.BALANCE_ADJUST, encode(payload), { timeout: 2000 });
return decode(m.data);
}
export function natsPublish(subject: string, payload: unknown): void {
void nc().then((c) => {
if (c) c.publish(subject, encode(payload));
});
}

View file

@ -6,6 +6,7 @@ export function meter(
endpointId: string,
repo: UsageRepo,
pricing: PricingContext,
onRecord?: (entry: { customerId: string; endpointId: string; cents: number }) => void,
): RequestHandler {
return (req, res, next) => {
const customer = req.customer;
@ -34,14 +35,16 @@ export function meter(
return;
}
repo.record({
const entry = {
customerId: customer.id,
endpointId,
cents: quote.totalCents,
metadataBytes,
attachmentBytes,
timestamp: new Date(),
});
};
repo.record(entry);
onRecord?.({ customerId: customer.id, endpointId, cents: quote.totalCents });
res.locals.quote = quote;
next();
};

11
packages/zappier/src/nats-shim.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module 'nats' {
export function connect(opts: unknown): Promise<{
request(s: string, d: Uint8Array, o: { timeout: number }): Promise<{ data: Uint8Array }>;
publish(s: string, d: Uint8Array): void;
close(): Promise<void>;
}>;
export function StringCodec(): {
encode(s: string): Uint8Array;
decode(u: Uint8Array): string;
};
}

View file

@ -14,6 +14,9 @@ import { applyMonthlyCredit } from './billing/credit';
import { InvoiceRepo } from './invoicing';
import { RateCard, TierConfig } from './pricing';
import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish, natsStatement, natsAdjust } from './billing-nats';
/**
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
@ -44,6 +47,7 @@ export interface PortalDeps {
qr: (uri: string) => Promise<string>;
sessionTtlMs?: number;
now?: () => number;
credits?: CreditLedger;
}
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@ -198,6 +202,24 @@ export function portalRouter(deps: PortalDeps): Router {
res.json({ tiers: deps.tiers(), rateCard: deps.rateCard() });
});
router.get('/statement', async (req, res) => {
const fromNats = await natsStatement(req.customer!.id);
if (fromNats) {
res.json({ ...fromNats, source: 'nats' });
return;
}
res.json({
...composeStatement({
customerId: req.customer!.id,
prepaidCents: req.customer!.balanceCents ?? 0,
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),
usage: deps.usage.listFor(req.customer!.id),
invoices: deps.invoices.list({ customerId: req.customer!.id }),
}),
source: 'local',
});
});
router.get('/invoices/:id', (req, res) => {
const invoice = deps.invoices.get(req.params.id);
if (!invoice || invoice.customerId !== req.customer!.id) {
@ -263,6 +285,20 @@ export function portalRouter(deps: PortalDeps): Router {
balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents,
};
if (result.creditedCents > 0) save(deps, customer);
if (result.creditedCents > 0) {
natsPublish(BILLING_SUBJECTS.PAYMENT_RECORDED, {
customerId: customer.id,
cents: result.creditedCents,
reason: 'reload',
});
void natsAdjust({
customerId: customer.id,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',
kind: 'payment',
});
}
res.json({
balanceCents: customer.balanceCents,
mode: result.mode,

View file

@ -0,0 +1,38 @@
import { CreditAdjustment } from './credits';
import { Invoice } from './invoicing';
import { UsageEntry } from './usage';
export function composeStatement(args: {
customerId: string;
prepaidCents: number;
credits: CreditAdjustment[];
usage: UsageEntry[];
invoices: Invoice[];
}) {
const payments = args.invoices
.filter((i) => i.customerId === args.customerId && (i.status === 'paid' || i.status === 'issued'))
.map((i) => ({
id: i.id,
customerId: i.customerId,
cents: i.billableCents,
kind: i.status === 'paid' ? 'invoice-paid' : 'invoice-issued',
reason: i.period,
at: new Date(i.paidAtMs || i.issuedAtMs || Date.now()).toISOString(),
}));
return {
customerId: args.customerId,
prepaidCents: args.prepaidCents,
credits: args.credits.filter((c) => c.customerId === args.customerId),
usage: args.usage
.filter((u) => u.customerId === args.customerId)
.slice(-50)
.reverse()
.map((u) => ({
customerId: u.customerId,
endpointId: u.endpointId,
cents: u.cents,
at: u.timestamp.toISOString(),
})),
payments,
};
}

View file

@ -34,6 +34,18 @@ describe('zappier-edge operational wiring', () => {
}
});
it('customers CS and sales can review a statement', async () => {
const { app } = buildApp();
await request(app)
.post('/admin/api/credits')
.set(ADMIN)
.send({ customerId: 'cust_1', cents: 250, reason: 'goodwill', agent: 'cs' });
const st = await request(app).get('/admin/api/statement/cust_1').set(ADMIN);
expect(st.status).toBe(200);
expect(st.body.prepaidCents).toBeGreaterThanOrEqual(250);
expect(st.body.credits.length).toBeGreaterThan(0);
});
it('customer service can add prepaid credits', async () => {
const { app } = buildApp();
const add = await request(app)

View file

@ -279,6 +279,12 @@ describe('portal billing', () => {
expect(res.body.mode).toBe('dev');
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
expect(me.body.balanceCents).toBe(2500);
const st = await request(app).get('/portal/api/statement').set('authorization', `Bearer ${token}`);
expect(st.status).toBe(200);
expect(st.body.prepaidCents).toBe(2500);
expect(Array.isArray(st.body.credits)).toBe(true);
expect(Array.isArray(st.body.usage)).toBe(true);
expect(Array.isArray(st.body.payments)).toBe(true);
});
it('rejects out-of-range reload amounts', async () => {