Clean prepaid SoT, identity mailbox, public access planes, leaf policy, fleet spawn
Some checks are pending
offline / test (push) Waiting to run

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.
This commit is contained in:
George Lambert 2026-09-11 17:15:16 -04:00
parent 345aeeead9
commit ddf772454b
153 changed files with 2236 additions and 116 deletions

View file

@ -13,7 +13,8 @@ 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';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>();
@ -229,7 +230,7 @@ export function adminRouter(
/* ---------------- customer service: credits ---------------- */
router.post('/credits', (req, res) => {
router.post('/credits', async (req, res) => {
const { customerId, cents, reason, agent } = req.body ?? {};
if (typeof customerId !== 'string' || !Number.isFinite(Number(cents))) {
res.status(400).json({ error: 'customerId and cents required' });
@ -247,9 +248,7 @@ export function adminRouter(
reason: typeof reason === 'string' ? reason : 'credit adjustment',
agent: typeof agent === 'string' ? agent : 'admin',
});
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
void natsAdjust(
const row = await ledgerAdjust(
{
customerId,
veraeUserId: customer.veraeUserId,
@ -260,7 +259,12 @@ export function adminRouter(
},
'staff',
);
res.status(201).json(rec);
let prepaid = customer.balanceCents ?? 0;
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += delta;
customers.save({ ...customer, balanceCents: prepaid });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
res.status(201).json({ ...rec, prepaidCents: prepaid });
});
router.get('/credits', (req, res) => {
@ -274,9 +278,9 @@ export function adminRouter(
res.status(404).json({ error: 'customer not found' });
return;
}
const fromNats = await natsStatement(customer.id, 'staff', customer.veraeUserId);
if (fromNats) {
res.json({ ...fromNats, name: customer.name, tierId: customer.tierId, source: 'nats' });
const fromBooks = await ledgerStatement(customer.id, 'staff', customer.veraeUserId);
if (fromBooks) {
res.json({ ...fromBooks, name: customer.name, tierId: customer.tierId, source: fromBooks.source || 'account-balance' });
return;
}
res.json({

View file

@ -33,8 +33,9 @@ const payments: PaymentClient = hasRealStripeKey(process.env.STRIPE_SECRET_KEY)
const { app } = buildApp({ usage, customers, pricingStore, invoices, adminUsers, sessions, payments });
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Zappier API listening on http://localhost:${port}`);
const bind = process.env.BIND ?? '127.0.0.1';
app.listen(port, bind, () => {
console.log(`Zappier API listening on http://${bind}:${port}`);
console.log(`OpenAPI docs at http://localhost:${port}/docs`);
console.log(`Customer portal at http://localhost:${port}/portal`);
console.log(`Admin console at http://localhost:${port}/admin`);

View file

@ -0,0 +1,50 @@
/**
* 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 type { AccessPlane } from './billing-nats';
export type PrepaidRow = {
customerId: string;
veraeUserId?: string;
cents: number;
reason: string;
agent: string;
kind?: string;
prepaidCents?: number;
};
function booksUrl(): string {
return (process.env.ACCOUNT_BALANCE_URL || '').replace(/\/$/, '');
}
export function booksConfigured(): boolean {
return Boolean(process.env.NATS_URL || booksUrl());
}
export async function ledgerAdjust(row: PrepaidRow, plane: AccessPlane): Promise<PrepaidRow | null> {
const viaNats = await natsAdjust(row, plane);
if (viaNats && typeof viaNats === 'object' && viaNats !== null && 'prepaidCents' in viaNats) {
return viaNats as PrepaidRow;
}
const base = booksUrl();
if (!base) return null;
const r = await fetch(`${base}/adjust`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(row),
});
if (!r.ok) return null;
return (await r.json()) as PrepaidRow;
}
export async function ledgerStatement(customerId: string, plane: AccessPlane, veraeUserId?: string) {
const nats = await natsStatement(customerId, plane, veraeUserId);
if (nats) return nats;
const base = booksUrl();
if (!base) return null;
const r = await fetch(`${base}/statement/${encodeURIComponent(customerId)}`);
if (!r.ok) return null;
return r.json();
}

View file

@ -16,8 +16,9 @@ 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';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { bindVeraeUser } from './verae-bind';
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
/**
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
@ -209,9 +210,9 @@ export function portalRouter(deps: PortalDeps): Router {
});
router.get('/statement', async (req, res) => {
const fromNats = await natsStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromNats) {
res.json({ ...fromNats, source: 'nats' });
const fromBooks = await ledgerStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromBooks) {
res.json({ ...fromBooks, source: fromBooks.source || 'account-balance' });
return;
}
res.json({
@ -287,26 +288,12 @@ export function portalRouter(deps: PortalDeps): Router {
return;
}
const result = await deps.payments.reload(req.customer!, amountCents);
const customer = {
...req.customer!,
balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents,
};
if (result.creditedCents > 0) save(deps, customer);
let prepaid = req.customer!.balanceCents ?? 0;
if (result.creditedCents > 0) {
natsPublish(
BILLING_SUBJECTS.PAYMENT_RECORDED,
const row = await ledgerAdjust(
{
customerId: customer.id,
veraeUserId: customer.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
},
'web',
);
void natsAdjust(
{
customerId: customer.id,
veraeUserId: customer.veraeUserId,
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',
@ -314,9 +301,22 @@ export function portalRouter(deps: PortalDeps): Router {
},
'web',
);
if (row && typeof row.prepaidCents === 'number') prepaid = row.prepaidCents;
else if (!booksConfigured()) prepaid += result.creditedCents;
save(deps, { ...req.customer!, balanceCents: prepaid });
natsPublish(
BILLING_SUBJECTS.PAYMENT_RECORDED,
{
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
},
'web',
);
}
res.json({
balanceCents: customer.balanceCents,
balanceCents: prepaid,
mode: result.mode,
...(result.clientSecret ? { clientSecret: result.clientSecret } : {}),
});

View file

@ -28,7 +28,20 @@ function mockBind(email: string): VeraeBind {
* Register or look up the customer on api.veraetime.net.
* MOCK_VERAE (default) or missing VERAE_API_BASE_URL stable id, no network.
*/
export async function bindVeraeUser(email: string): Promise<VeraeBind> {
export async function bindVeraeUser(email: string, customerId?: string): Promise<VeraeBind> {
const identity = (process.env.IDENTITY_URL || '').replace(/\/$/, '');
if (identity) {
try {
const r = await fetch(`${identity}/bind`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, customerId }),
});
if (r.ok) return (await r.json()) as VeraeBind;
} catch {
/* fall through */
}
}
const mock = process.env.MOCK_VERAE !== 'false';
const base = (process.env.VERAE_API_BASE_URL || '').replace(/\/$/, '');
if (mock || !base) return mockBind(email);