Wire zappier-edge into the live stack and add billing department APIs
Some checks are pending
offline / test (push) Waiting to run
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:
parent
65bfa544b2
commit
a1a5b957fd
44 changed files with 822 additions and 18 deletions
36
packages/zappier/src/accounting-export.ts
Normal file
36
packages/zappier/src/accounting-export.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
29
packages/zappier/src/credits.ts
Normal file
29
packages/zappier/src/credits.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
packages/zappier/src/upstream.ts
Normal file
45
packages/zappier/src/upstream.ts
Normal 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;
|
||||
}
|
||||
75
packages/zappier/tests/health-proxy-credits.test.ts
Normal file
75
packages/zappier/tests/health-proxy-credits.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import request from 'supertest';
|
||||
import { buildApp } from '../src/app';
|
||||
|
||||
const KEY = 'key-ada';
|
||||
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
|
||||
|
||||
describe('zappier-edge operational wiring', () => {
|
||||
it('GET /health is public', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.role).toBe('zappier-edge');
|
||||
});
|
||||
|
||||
it('proxies timestamp to middleware when ZAPPIER_UPSTREAM is set', async () => {
|
||||
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain('/zapier/v1/timestamp');
|
||||
return new Response(JSON.stringify({ jobId: 'mw-1', sha256: 'abc' }), {
|
||||
status: 202,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).post('/v1/timestamp').set('x-api-key', KEY).send({ data: 'hello' });
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.jobId).toBe('mw-1');
|
||||
expect(res.body.quote.totalCents).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
}
|
||||
});
|
||||
|
||||
it('customer service can add prepaid credits', async () => {
|
||||
const { app } = buildApp();
|
||||
const add = await request(app)
|
||||
.post('/admin/api/credits')
|
||||
.set(ADMIN)
|
||||
.send({ customerId: 'cust_1', cents: 500, reason: 'goodwill', agent: 'cs-anna' });
|
||||
expect(add.status).toBe(201);
|
||||
expect(add.body.cents).toBe(500);
|
||||
const list = await request(app).get('/admin/api/credits?customerId=cust_1').set(ADMIN);
|
||||
expect(list.body.credits[0].reason).toBe('goodwill');
|
||||
});
|
||||
|
||||
it('sales quote returns per-customer multiplier', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).get('/admin/api/sales/quote/cust_2').set(ADMIN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tierId).toBe('pro');
|
||||
});
|
||||
|
||||
it('exports issued invoices as QuickBooks IIF', async () => {
|
||||
const { app, usage } = buildApp();
|
||||
usage.record({
|
||||
customerId: 'cust_2',
|
||||
endpointId: 'transform',
|
||||
cents: 400,
|
||||
metadataBytes: 0,
|
||||
attachmentBytes: 0,
|
||||
timestamp: new Date('2026-07-05T10:00:00Z'),
|
||||
});
|
||||
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
|
||||
await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
|
||||
const iif = await request(app).get('/admin/api/exports/quickbooks.iif?period=2026-07').set(ADMIN);
|
||||
expect(iif.status).toBe(200);
|
||||
expect(iif.text).toContain('TRNS');
|
||||
expect(iif.text).toContain('INV-2026-07-0001');
|
||||
const csv = await request(app).get('/admin/api/exports/accounting.csv?period=2026-07').set(ADMIN);
|
||||
expect(csv.text).toContain('invoice_id');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue