Wire zappier-edge into the live stack and add billing department APIs
Some checks are pending
offline / test (push) Waiting to run

Fleet now spawns the real zappier and middleware processes. Metered
timestamp/receipt/hash calls proxy to middleware when ZAPPIER_UPSTREAM
is set. CS credits, sales per-customer pricing, and QuickBooks export
are separate repos plugged into zappier-edge admin.
This commit is contained in:
George Lambert 2026-09-11 15:15:55 -04:00
parent 65bfa544b2
commit a1a5b957fd
44 changed files with 822 additions and 18 deletions

View file

@ -0,0 +1,36 @@
import { Invoice } from './invoicing';
function iifDate(ms: number): string {
const d = new Date(ms);
return `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')}/${d.getUTCFullYear()}`;
}
/** QuickBooks IIF journal for issued/paid invoices (Accounts Receivable + Sales). */
export function invoicesToQuickBooksIif(invoices: Invoice[]): string {
const lines = [
'!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM',
'!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM',
'!ENDTRNS',
];
for (const inv of invoices) {
if (inv.status === 'draft') continue;
const date = iifDate(inv.issuedAtMs || inv.paidAtMs || Date.now());
const dollars = (inv.billableCents / 100).toFixed(2);
const name = inv.customerId;
lines.push(`TRNS\t\tINVOICE\t${date}\tAccounts Receivable\t${name}\t${dollars}\t${inv.id}`);
lines.push(`SPL\t\tINVOICE\t${date}\tSales\t${name}\t-${dollars}\t${inv.id}`);
lines.push('ENDTRNS');
}
return lines.join('\n') + '\n';
}
export function invoicesToAccountingCsv(invoices: Invoice[]): string {
const header = 'invoice_id,customer_id,period,status,billing_type,total_cents,credit_cents,billable_cents,po_number';
const rows = invoices.map(
(i) =>
[i.id, i.customerId, i.period, i.status, i.billingType, i.totalCents, i.creditCents, i.billableCents, i.poNumber || ''].join(
',',
),
);
return [header, ...rows].join('\n') + '\n';
}

View file

@ -10,6 +10,8 @@ import { PriceRule, PricingStore, TierConfig } from './pricing';
import { PROJECT_ROOT } from './paths';
import { billingRows, toCsv, usageTrend } from './reports';
import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
// Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>();
@ -139,6 +141,7 @@ export function adminRouter(
store: PricingStore,
customers: CustomerRepo,
accounting: AccountingDeps,
credits: CreditLedger = new CreditLedger(),
): Router {
const router = Router();
@ -222,6 +225,70 @@ export function adminRouter(
res.json({ ok: true });
});
/* ---------------- customer service: credits ---------------- */
router.post('/credits', (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' });
return;
}
const customer = customers.list().find((c) => c.id === customerId);
if (!customer) {
res.status(404).json({ error: 'customer not found' });
return;
}
const delta = Math.trunc(Number(cents));
const rec = credits.add({
customerId,
cents: delta,
reason: typeof reason === 'string' ? reason : 'credit adjustment',
agent: typeof agent === 'string' ? agent : 'admin',
});
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
res.status(201).json(rec);
});
router.get('/credits', (req, res) => {
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined;
res.json({ credits: credits.list(customerId) });
});
router.get('/sales/quote/:id', (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 tier = store.getTiers().find((t) => t.id === customer.tierId);
res.json({
customerId: customer.id,
name: customer.name,
tierId: customer.tierId,
multiplierOverride: customer.multiplierOverride ?? null,
monthlyCreditCents: tier?.monthlyCreditCents ?? 0,
listMultiplier: tier?.multiplier ?? 1,
});
});
router.get('/exports/quickbooks.iif', (req, res) => {
const period = typeof req.query.period === 'string' ? req.query.period : undefined;
const invoices = accounting.invoices.list({ ...(period ? { period } : {}) });
res
.type('text/plain')
.set('content-disposition', 'attachment; filename="zappier-quickbooks.iif"')
.send(invoicesToQuickBooksIif(invoices));
});
router.get('/exports/accounting.csv', (req, res) => {
const period = typeof req.query.period === 'string' ? req.query.period : undefined;
const invoices = accounting.invoices.list({ ...(period ? { period } : {}) });
res
.type('text/csv')
.set('content-disposition', 'attachment; filename="zappier-accounting.csv"')
.send(invoicesToAccountingCsv(invoices));
});
/* ---------------- accounting: invoices ---------------- */
router.post('/invoices/generate', (req, res) => {

View file

@ -25,6 +25,7 @@ import { InMemorySessionRepo, SessionRepo } from './accounts';
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
import { PROJECT_ROOT } from './paths';
import { PaymentClient, portalRouter } from './portal';
import { proxyVerae } from './upstream';
export interface StoredItem {
id: string;
@ -143,6 +144,10 @@ export function buildApp(deps: AppDeps = {}): {
);
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
app.get('/health', (_req, res) => {
res.json({ ok: true, role: 'zappier-edge' });
});
app.use('/v1', apiKeyAuth(customers));
app.use(
OpenApiValidator.middleware({
@ -162,7 +167,8 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
});
app.post('/v1/timestamp', meter('timestamp', usage, pricing), (req, res) => {
app.post('/v1/timestamp', meter('timestamp', usage, pricing), async (req, res) => {
if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return;
const data = req.body?.data != null ? String(req.body.data) : '';
const sha256 =
(req.body?.sha256 && String(req.body.sha256).toLowerCase()) ||
@ -184,7 +190,8 @@ export function buildApp(deps: AppDeps = {}): {
res.status(202).json({ jobId, sha256, existing: false, timestamp });
});
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), (req, res) => {
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), 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) {
res.status(404).json({ error: 'Job not found' });
@ -201,7 +208,8 @@ export function buildApp(deps: AppDeps = {}): {
});
});
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), (req, res) => {
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), 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);
if (!rec) {

View file

@ -0,0 +1,29 @@
export interface CreditAdjustment {
id: string;
customerId: string;
cents: number;
reason: string;
agent: string;
at: string;
}
export class CreditLedger {
private rows: CreditAdjustment[] = [];
add(row: Omit<CreditAdjustment, 'id' | 'at'> & { id?: string; at?: string }): CreditAdjustment {
const rec: CreditAdjustment = {
id: row.id || `crd_${Date.now().toString(36)}`,
customerId: row.customerId,
cents: row.cents,
reason: row.reason,
agent: row.agent,
at: row.at || new Date().toISOString(),
};
this.rows.unshift(rec);
return rec;
}
list(customerId?: string): CreditAdjustment[] {
return customerId ? this.rows.filter((r) => r.customerId === customerId) : this.rows;
}
}

View file

@ -0,0 +1,45 @@
import { Request, Response } from 'express';
export type UpstreamFetch = typeof fetch;
/**
* After zappier meters the call, forward Verae operations to middleware.
* When ZAPPIER_UPSTREAM is unset, callers keep the local mock.
*/
export async function proxyVerae(
req: Request,
res: Response,
pathname: string,
fetchImpl: UpstreamFetch = fetch,
): Promise<boolean> {
const base = (process.env.ZAPPIER_UPSTREAM || '').replace(/\/$/, '');
if (!base) return false;
const url = new URL(pathname, `${base}/`);
for (const [k, v] of Object.entries(req.query)) {
if (typeof v === 'string') url.searchParams.set(k, v);
}
const headers: Record<string, string> = { accept: 'application/json' };
const key = req.header('x-api-key');
if (key) headers['x-api-key'] = key;
const auth = req.header('authorization');
if (auth) headers.authorization = auth;
const method = req.method.toUpperCase();
const init: RequestInit = { method, headers };
if (method !== 'GET' && method !== 'HEAD') {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(req.body ?? {});
}
const r = await fetchImpl(url.toString(), init);
const text = await r.text();
let body: unknown = text;
try {
body = text ? JSON.parse(text) : {};
} catch {
/* keep text */
}
if (body && typeof body === 'object' && !Array.isArray(body) && res.locals.quote) {
(body as Record<string, unknown>).quote = res.locals.quote;
}
res.status(r.status).json(body);
return true;
}