Clean prepaid SoT, identity mailbox, public access planes, leaf policy, fleet spawn
Some checks are pending
offline / test (push) Waiting to run
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:
parent
345aeeead9
commit
ddf772454b
153 changed files with 2236 additions and 116 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
50
packages/zappier/src/ledger.ts
Normal file
50
packages/zappier/src/ledger.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue