Add customer names on the ledger, staff session login, and exclusive jobs.events.
Some checks are pending
offline / test (push) Waiting to run

Account-balance stores display names and looks up by name. Edge writes
names on customer create/edit; staff UIs join from edge when needed.
New verae-staff-session issues a host cookie; department HTML redirects
when STAFF_AUTH=1. JOBS_EVENTS_EXCLUSIVE lets jobs-events own the durable
consumer. Catalog index is cards; disabled fleet machines are grey.
This commit is contained in:
George Lambert 2026-09-11 18:11:21 -04:00
parent cb07f5b321
commit 9cc0018708
38 changed files with 533 additions and 68 deletions

View file

@ -226,10 +226,10 @@ async function viewInvoice(id) {
function renderStatement() {
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
const emptyCard = (title) =>
`<div class="empty">${EMPTY_SVG}<h3>${title}</h3><p>Nothing recorded yet.</p></div>`;
const row = (list, cols) => {
if (!list || !list.length) {
return `<tr><td colspan="${cols.length}"><div class="empty" style="padding:1rem">${EMPTY_SVG}<h3>Nothing here yet</h3></div></td></tr>`;
}
if (!list || !list.length) return '';
return list
.map(
(r) =>
@ -251,15 +251,21 @@ function renderStatement() {
<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>Amount</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>Amount</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>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
<div class="card"><h3>Credits</h3>${
st.credits?.length
? `<table><thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead><tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table>`
: emptyCard('No credits yet')
}</div>
<div class="card"><h3>Usage</h3>${
st.usage?.length
? `<table><thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead><tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table>`
: emptyCard('No usage yet')
}</div>
<div class="card"><h3>Payments</h3>${
st.payments?.length
? `<table><thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead><tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table>`
: emptyCard('No payments yet')
}</div>`;
}
/* ---------------- billing ---------------- */

View file

@ -14,7 +14,7 @@ import { CreditLedger } from './credits';
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>();
@ -197,6 +197,7 @@ export function adminRouter(
apiKey: `key-${randomBytes(12).toString('hex')}`,
};
customers.save(customer);
void ledgerPutCustomer({ customerId: customer.id, name: customer.name });
res.status(201).json(customer);
});
@ -216,7 +217,7 @@ export function adminRouter(
res.status(400).json({ error: 'billingType must be stripe or purchase_order' });
return;
}
customers.save({
const next = {
...existing,
...(name !== undefined ? { name } : {}),
...(tierId !== undefined ? { tierId } : {}),
@ -224,7 +225,9 @@ export function adminRouter(
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
...(billingType !== undefined ? { billingType } : {}),
...(email !== undefined ? { email } : {}),
});
};
customers.save(next);
void ledgerPutCustomer({ customerId: next.id, name: next.name, veraeUserId: next.veraeUserId });
res.json({ ok: true });
});
@ -286,6 +289,7 @@ export function adminRouter(
res.json({
...composeStatement({
customerId: customer.id,
name: customer.name,
veraeUserId: customer.veraeUserId,
prepaidCents: customer.balanceCents ?? 0,
credits: credits.list(customer.id),

View file

@ -8,6 +8,7 @@ export const BILLING_SUBJECTS = {
USAGE_RECORDED: 'verae.billing.usage.recorded',
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
CREDIT_APPLIED: 'verae.billing.credit.applied',
CUSTOMER_PUT: 'verae.billing.customer.put',
};
export const AUTHZ_CHECK = 'verae.access.authz.check';
@ -15,6 +16,7 @@ export type AccessPlane = 'zapier' | 'web' | 'api' | 'leaf' | 'staff';
export type BillingStatement = {
customerId: string;
name?: string;
veraeUserId?: string;
prepaidCents: number;
credits: unknown[];

View file

@ -2,7 +2,7 @@
* Prepaid mutations. Account-balance is the writer when NATS or HTTP books exist.
* Tests (no NATS_URL, no ACCOUNT_BALANCE_URL) keep a local cache only.
*/
import { natsAdjust, natsStatement } from './billing-nats';
import { natsAdjust, natsPublish, natsStatement } from './billing-nats';
import type { AccessPlane } from './billing-nats';
export type PrepaidRow = {
@ -39,6 +39,21 @@ export async function ledgerAdjust(row: PrepaidRow, plane: AccessPlane): Promise
return (await r.json()) as PrepaidRow;
}
export async function ledgerPutCustomer(row: {
customerId: string;
name?: string;
veraeUserId?: string;
}): Promise<void> {
natsPublish('verae.billing.customer.put', row, 'staff');
const base = booksUrl();
if (!base) return;
await fetch(`${base}/customer`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(row),
}).catch(() => {});
}
export async function ledgerStatement(customerId: string, plane: AccessPlane, veraeUserId?: string) {
const nats = await natsStatement(customerId, plane, veraeUserId);
if (nats) return nats;

View file

@ -212,12 +212,17 @@ export function portalRouter(deps: PortalDeps): Router {
router.get('/statement', async (req, res) => {
const fromBooks = await ledgerStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromBooks) {
res.json({ ...fromBooks, source: fromBooks.source || 'account-balance' });
res.json({
...fromBooks,
name: req.customer!.name,
source: fromBooks.source || 'account-balance',
});
return;
}
res.json({
...composeStatement({
customerId: req.customer!.id,
name: req.customer!.name,
veraeUserId: req.customer!.veraeUserId,
prepaidCents: req.customer!.balanceCents ?? 0,
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),

View file

@ -4,6 +4,7 @@ import { UsageEntry } from './usage';
export function composeStatement(args: {
customerId: string;
name?: string;
veraeUserId?: string;
prepaidCents: number;
credits: CreditAdjustment[];
@ -22,6 +23,7 @@ export function composeStatement(args: {
}));
return {
customerId: args.customerId,
name: args.name,
veraeUserId: args.veraeUserId,
prepaidCents: args.prepaidCents,
credits: args.credits.filter((c) => c.customerId === args.customerId),