diff --git a/docs/02-architecture/composition.md b/docs/02-architecture/composition.md index e2568e5..f225729 100644 --- a/docs/02-architecture/composition.md +++ b/docs/02-architecture/composition.md @@ -5,7 +5,8 @@ Users → Zapier UI Zapier cloud runs packages/verae-zapier --HTTPS, x-api-key--> packages/zappier /v1/* (meter, quote, 401/403, usage) - --internal HTTPS--> packages/verae-zapier-middleware /zapier/v1/* + --ZAPPIER_UPSTREAM--> packages/verae-zapier-middleware /zapier/v1/* +CS :3011, Sales :3012, Accounting/QuickBooks :3013 --> zappier-edge admin --sync--> api.veraetime.net or MOCK_VERAE --NATS--> workers --> Verae + Zapier REST Hooks diff --git a/packages/docs-master/modules/zappier-accounting-export/NATS.md b/packages/docs-master/modules/zappier-accounting-export/NATS.md new file mode 100644 index 0000000..2cac4e8 --- /dev/null +++ b/packages/docs-master/modules/zappier-accounting-export/NATS.md @@ -0,0 +1,3 @@ +# zappier-accounting-export NATS + +None. diff --git a/packages/docs-master/modules/zappier-accounting-export/SUMMARY.md b/packages/docs-master/modules/zappier-accounting-export/SUMMARY.md new file mode 100644 index 0000000..813f130 --- /dev/null +++ b/packages/docs-master/modules/zappier-accounting-export/SUMMARY.md @@ -0,0 +1,3 @@ +# zappier-accounting-export + +Exports zappier invoices to QuickBooks IIF and accounting CSV. diff --git a/packages/docs-master/modules/zappier-customer-service/NATS.md b/packages/docs-master/modules/zappier-customer-service/NATS.md new file mode 100644 index 0000000..608faa3 --- /dev/null +++ b/packages/docs-master/modules/zappier-customer-service/NATS.md @@ -0,0 +1,3 @@ +# zappier-customer-service NATS + +None. HTTPS only to zappier-edge admin. diff --git a/packages/docs-master/modules/zappier-customer-service/SUMMARY.md b/packages/docs-master/modules/zappier-customer-service/SUMMARY.md new file mode 100644 index 0000000..5d39fef --- /dev/null +++ b/packages/docs-master/modules/zappier-customer-service/SUMMARY.md @@ -0,0 +1,3 @@ +# zappier-customer-service + +CS department API for prepaid credit additions. Writes `balanceCents` on zappier-edge. diff --git a/packages/docs-master/modules/zappier-sales-pricing/NATS.md b/packages/docs-master/modules/zappier-sales-pricing/NATS.md new file mode 100644 index 0000000..2d71da6 --- /dev/null +++ b/packages/docs-master/modules/zappier-sales-pricing/NATS.md @@ -0,0 +1,3 @@ +# zappier-sales-pricing NATS + +None. diff --git a/packages/docs-master/modules/zappier-sales-pricing/SUMMARY.md b/packages/docs-master/modules/zappier-sales-pricing/SUMMARY.md new file mode 100644 index 0000000..6c46037 --- /dev/null +++ b/packages/docs-master/modules/zappier-sales-pricing/SUMMARY.md @@ -0,0 +1,3 @@ +# zappier-sales-pricing + +Sales department per-customer tier and multiplier. Writes through zappier-edge `/admin/api/customers`. diff --git a/packages/overview/01-system.md b/packages/overview/01-system.md index 436b7d3..035063a 100644 --- a/packages/overview/01-system.md +++ b/packages/overview/01-system.md @@ -12,6 +12,7 @@ Verae Time proves a SHA-256 existed at a given time. Zapier lets customers regis |-------|------|---------| | Zapier cloud | `verae-zapier-app`, `verae-activate` | HTTPS to zappier-edge | | Commercial edge | `zappier-edge` portal, admin, `x-api-key`, Stripe meter | Public HTTPS | +| CS / sales / accounting | `zappier-customer-service`, `zappier-sales-pricing`, `zappier-accounting-export` | Loopback/private HTTP into zappier-edge admin | | Middleware HTTP | `/zapier/v1/*` job id + wait | Public HTTPS from edge only | | NATS cluster | JetStream subjects under `verae.*` | Private; loopback or SSH tunnel | | Workers | poller, webhook-deliver, aggregator | NATS + HTTPS to chain or Zapier hooks | @@ -21,6 +22,6 @@ Verae Time proves a SHA-256 existed at a given time. Zapier lets customers regis ## Request in one sentence -A Zap step POSTs to zappier-edge; edge meters and forwards to middleware; middleware splits hash vs files, writes the hash (or Merkle root) to the chain, publishes `verae.zapier.jobs.watch`, and either returns `jobId` or waits on `verae.zapier.jobs.events`. Attached metadata and bulk-summary **leaves** are found later by broadcasting `verae.archive.query` to every archive/tree node. +A Zap step POSTs to zappier-edge (`x-api-key`); edge meters the call and, when `ZAPPIER_UPSTREAM` is set, forwards timestamp/receipt/hash to middleware `/zapier/v1`. Middleware splits hash vs files, writes the hash (or Merkle root) to the chain, publishes `verae.zapier.jobs.watch`, and either returns `jobId` or waits on `verae.zapier.jobs.events`. Customer-service credits, sales per-customer multipliers, invoices, payment history, and QuickBooks export sit on zappier-edge (department APIs on :3011–:3013). Attached metadata and bulk-summary **leaves** are found later by broadcasting `verae.archive.query` to every archive/tree node. See [diagrams](08-diagrams.md). diff --git a/packages/overview/02-modules-and-repos.md b/packages/overview/02-modules-and-repos.md index 50e994f..0074c7d 100644 --- a/packages/overview/02-modules-and-repos.md +++ b/packages/overview/02-modules-and-repos.md @@ -7,7 +7,10 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`, | **overview** | `packages/overview` | This high-level map | | **verae-nats-process** | `packages/verae-nats-process` | **Template** for a new addressed NATS process | | **master-zapier-plan-draft** | workspace root | Combined snapshot (`main` and `master`) | -| **zappier-edge** | `packages/zappier` | Metered HTTPS, portal, admin, Stripe `zappier.api_cents` | +| **zappier-edge** | `packages/zappier` | Metered HTTPS, portal, admin, Stripe; **proxies Verae calls to middleware** | +| **zappier-customer-service** | `packages/zappier-customer-service` | CS goodwill credits onto prepaid balances | +| **zappier-sales-pricing** | `packages/zappier-sales-pricing` | Sales per-customer tier / multiplier | +| **zappier-accounting-export** | `packages/zappier-accounting-export` | QuickBooks IIF + accounting CSV | | **verae-middleware** | `packages/verae-zapier-middleware` | `/zapier/v1`, job wait, NATS publishers/workers | | **verae-zapier-app** | `packages/verae-zapier` | Full Zapier nouns (timestamp, wait, batch, hash + tree lookup) | | **verae-activate** | `packages/verae-activate` | Activate-now (Add Numbers, Echo, SHA256, mock timestamp) | diff --git a/packages/overview/08-diagrams.md b/packages/overview/08-diagrams.md index c2e07f6..6b8ad8a 100644 --- a/packages/overview/08-diagrams.md +++ b/packages/overview/08-diagrams.md @@ -9,7 +9,10 @@ SVG files in [`diagrams/`](diagrams/). The same shapes are repeated below in mer ```mermaid flowchart LR Z[Zapier cloud apps] -->|HTTPS x-api-key| E[zappier-edge] - E -->|HTTPS| M[verae-middleware] + CS[customer-service] --> E + SA[sales-pricing] --> E + AC[accounting-export QuickBooks] --> E + E -->|HTTPS metered| M[verae-middleware] M -->|HTTPS| C[Verae chain] M -->|JetStream| N[NATS 3-node cluster] N --> P[job-poller] diff --git a/packages/overview/diagrams/system.svg b/packages/overview/diagrams/system.svg index 687b74e..bf48d34 100644 --- a/packages/overview/diagrams/system.svg +++ b/packages/overview/diagrams/system.svg @@ -9,6 +9,12 @@ zappier-edge HTTPS :3000 meter + + CS credits + + Sales $ + + QBooks verae-middleware HTTPS :3100 wait diff --git a/packages/verae-fleet/fleet.json b/packages/verae-fleet/fleet.json index afc9c94..8dd4c7e 100644 --- a/packages/verae-fleet/fleet.json +++ b/packages/verae-fleet/fleet.json @@ -15,6 +15,9 @@ "services": { "nats": { "min": 1, "max": 1, "keepFloor": false, "enabled": true, "managed": false }, "zappier-edge": { "min": 1, "max": 1, "keepFloor": true, "enabled": true }, + "customer-service": { "min": 0, "max": 1, "keepFloor": false, "enabled": true }, + "sales-pricing": { "min": 0, "max": 1, "keepFloor": false, "enabled": true }, + "accounting-export": { "min": 0, "max": 1, "keepFloor": false, "enabled": true }, "middleware-http": { "min": 1, "max": 1, "keepFloor": true, "enabled": true }, "job-poller": { "min": 1, "max": 2, "keepFloor": true, "enabled": true }, "webhook-deliver": { "min": 1, "max": 2, "keepFloor": true, "enabled": true }, diff --git a/packages/verae-fleet/services/accounting-export.json b/packages/verae-fleet/services/accounting-export.json new file mode 100644 index 0000000..7a01971 --- /dev/null +++ b/packages/verae-fleet/services/accounting-export.json @@ -0,0 +1,21 @@ +{ + "id": "accounting-export", + "title": "Accounting export (QuickBooks)", + "kind": "http", + "package": "zappier-accounting-export", + "role": "accounting-export", + "managed": true, + "runtime": "HTTP :3013 IIF/CSV → zappier-edge invoices", + "health": { "type": "http", "path": "/health", "timeoutMs": 2000 }, + "ports": { "healthBase": 3013 }, + "spawn": { + "cwd": "../zappier-accounting-export", + "command": "node", + "args": ["src/server.js"] + }, + "env": { + "PORT": "3013", + "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000" + }, + "nats": { "in": [], "out": [] } +} diff --git a/packages/verae-fleet/services/customer-service.json b/packages/verae-fleet/services/customer-service.json new file mode 100644 index 0000000..991a4a9 --- /dev/null +++ b/packages/verae-fleet/services/customer-service.json @@ -0,0 +1,22 @@ +{ + "id": "customer-service", + "title": "Customer-service credits", + "kind": "http", + "package": "zappier-customer-service", + "role": "customer-service", + "managed": true, + "runtime": "HTTP :3011 CS credits → zappier-edge", + "health": { "type": "http", "path": "/health", "timeoutMs": 2000 }, + "ports": { "healthBase": 3011 }, + "spawn": { + "cwd": "../zappier-customer-service", + "command": "node", + "args": ["src/server.js"] + }, + "env": { + "PORT": "3011", + "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000" + }, + "nats": { "in": [], "out": [] }, + "notes": "Department API. Prepaid balances live on zappier-edge." +} diff --git a/packages/verae-fleet/services/middleware-http.json b/packages/verae-fleet/services/middleware-http.json index 5b819d2..d7a89d5 100644 --- a/packages/verae-fleet/services/middleware-http.json +++ b/packages/verae-fleet/services/middleware-http.json @@ -6,9 +6,19 @@ "role": "middleware-http", "managed": true, "runtime": "HTTPS :3100 /zapier/v1", - "health": { "type": "http", "path": "/health", "timeoutMs": 800 }, - "ports": { "healthBase": 13100 }, - "env": { "PORT": "3100", "NATS_URL": "nats://127.0.0.1:4222" }, + "health": { "type": "http", "path": "/health", "timeoutMs": 4000 }, + "ports": { "healthBase": 3100 }, + "spawn": { + "cwd": "../verae-zapier-middleware", + "command": "node", + "args": ["src/index.js"] + }, + "env": { + "PORT": "3100", + "NATS_URL": "nats://127.0.0.1:4222", + "NATS_ENABLED": "true", + "MOCK_VERAE": "true" + }, "nats": { "in": ["verae.zapier.jobs.events"], "out": ["verae.zapier.jobs.watch", "verae.zapier.webhooks.deliver"] diff --git a/packages/verae-fleet/services/sales-pricing.json b/packages/verae-fleet/services/sales-pricing.json new file mode 100644 index 0000000..359cb18 --- /dev/null +++ b/packages/verae-fleet/services/sales-pricing.json @@ -0,0 +1,21 @@ +{ + "id": "sales-pricing", + "title": "Sales per-customer pricing", + "kind": "http", + "package": "zappier-sales-pricing", + "role": "sales-pricing", + "managed": true, + "runtime": "HTTP :3012 quotes → zappier-edge", + "health": { "type": "http", "path": "/health", "timeoutMs": 2000 }, + "ports": { "healthBase": 3012 }, + "spawn": { + "cwd": "../zappier-sales-pricing", + "command": "node", + "args": ["src/server.js"] + }, + "env": { + "PORT": "3012", + "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000" + }, + "nats": { "in": [], "out": [] } +} diff --git a/packages/verae-fleet/services/zappier-edge.json b/packages/verae-fleet/services/zappier-edge.json index a43ca91..72d6381 100644 --- a/packages/verae-fleet/services/zappier-edge.json +++ b/packages/verae-fleet/services/zappier-edge.json @@ -5,10 +5,21 @@ "package": "zappier", "role": "zappier-edge", "managed": true, - "runtime": "HTTPS :3000 (portal, admin, meter)", - "health": { "type": "http", "path": "/health", "timeoutMs": 800 }, - "ports": { "healthBase": 13000 }, - "env": { "PORT": "3000" }, + "runtime": "HTTPS :3000 (portal, admin, meter, proxy to middleware)", + "health": { "type": "http", "path": "/health", "timeoutMs": 4000 }, + "ports": { "healthBase": 3000 }, + "spawn": { + "cwd": "../zappier", + "command": "npx", + "args": ["ts-node", "--transpile-only", "src/index.ts"] + }, + "env": { + "PORT": "3000", + "ZAPPIER_UPSTREAM": "http://127.0.0.1:3100", + "CS_SERVICE_URL": "http://127.0.0.1:3011", + "SALES_PRICING_URL": "http://127.0.0.1:3012", + "ACCOUNTING_EXPORT_URL": "http://127.0.0.1:3013" + }, "nats": { "in": [], "out": [] }, "notes": "Zapier x-api-key lands here. HTTPS to middleware only." } diff --git a/packages/verae-fleet/src/supervisor.js b/packages/verae-fleet/src/supervisor.js index 5bb543e..ab51f2b 100644 --- a/packages/verae-fleet/src/supervisor.js +++ b/packages/verae-fleet/src/supervisor.js @@ -16,6 +16,23 @@ import { sshSpawnWorker, sshKillWorker, sshHttp, sshCheck } from './ssh.js'; const WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.js'); +/** + * Real HTTP apps (zappier-edge, middleware) set `spawn` in services/*.json. + * Everything else is the fleet health worker. + */ +export function spawnLocal(root, spec, env, healthPort) { + const merged = { ...env, ...(spec.env || {}), PORT: String(healthPort) }; + if (spec.spawn?.command) { + const cwd = path.resolve(root, spec.spawn.cwd || '.'); + return spawn(spec.spawn.command, spec.spawn.args || [], { + env: merged, + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } + return spawn(process.execPath, [WORKER], { env: merged, stdio: ['ignore', 'pipe', 'pipe'] }); +} + export class Supervisor { /** * @param {object} [opts] @@ -184,7 +201,7 @@ export class Supervisor { return null; } } else { - child = spawn(process.execPath, [WORKER], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + child = spawnLocal(this.loaded.root, spec, env, healthPort); pid = child.pid; } const rec = { diff --git a/packages/verae-fleet/test/fleet.test.js b/packages/verae-fleet/test/fleet.test.js index c680cc0..8f343e1 100644 --- a/packages/verae-fleet/test/fleet.test.js +++ b/packages/verae-fleet/test/fleet.test.js @@ -1,7 +1,7 @@ import { describe, it, after } from 'node:test'; import assert from 'node:assert/strict'; import { loadFleet, listServices, FLEET_ROOT } from '../src/load.js'; -import { Supervisor } from '../src/supervisor.js'; +import { Supervisor, spawnLocal } from '../src/supervisor.js'; import { Monitor } from '../src/monitor.js'; function treeOnly(healthBase) { @@ -32,6 +32,20 @@ describe('verae-fleet', () => { for (const s of supervisors) await s.stopAll(); }); + it('spawnLocal runs spawn.command instead of the stub worker', async () => { + const child = spawnLocal( + FLEET_ROOT, + { spawn: { command: process.execPath, args: ['-e', 'process.stdout.write("ok"); process.exit(0)'] } }, + { ...process.env }, + 9, + ); + const out = []; + child.stdout.on('data', (c) => out.push(c)); + const code = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(code, 0); + assert.equal(Buffer.concat(out).toString(), 'ok'); + }); + it('lists every service with a config file and central replica spec', () => { const loaded = loadFleet(); const rows = listServices(loaded); @@ -39,6 +53,9 @@ describe('verae-fleet', () => { for (const need of [ 'nats', 'zappier-edge', + 'customer-service', + 'sales-pricing', + 'accounting-export', 'middleware-http', 'job-poller', 'webhook-deliver', diff --git a/packages/verae-ops/GETTING-STARTED.md b/packages/verae-ops/GETTING-STARTED.md index d4539e1..5246f95 100644 --- a/packages/verae-ops/GETTING-STARTED.md +++ b/packages/verae-ops/GETTING-STARTED.md @@ -10,7 +10,10 @@ This is the **top document** for install, first-up, and monitoring. Platform-spe | Piece | Job | Default | |-------|-----|---------| -| **zappier-edge** | Billing, API keys, customer portal, admin, Stripe | `:3000` public HTTPS | +| **zappier-edge** | Billing, API keys, customer portal, admin, Stripe; meters then **proxies** to middleware | `:3000` public HTTPS | +| **customer-service** | CS credit additions | `:3011` | +| **sales-pricing** | Per-customer sales quotes | `:3012` | +| **accounting-export** | QuickBooks IIF / CSV | `:3013` | | **verae-middleware** | Zapier `/zapier/v1`, chain, NATS | `:3100` | | **NATS JetStream** | Private jobs / archive | `:4222` **not public** | | **worm ×3, tree-node ×3** | Off-chain files and Merkle leaves | fleet floor | diff --git a/packages/verae-ops/docker-compose.yml b/packages/verae-ops/docker-compose.yml index 236543a..1c75416 100644 --- a/packages/verae-ops/docker-compose.yml +++ b/packages/verae-ops/docker-compose.yml @@ -53,6 +53,48 @@ services: ports: - "3000:3000" + customer-service: + <<: *node + image: node:22-alpine + working_dir: /app + volumes: + - ${MONOREPO:-..}/packages/zappier-customer-service:/app + command: ["node", "src/server.js"] + environment: + PORT: "3011" + ZAPPIER_ADMIN_URL: http://zappier-edge:3000 + ports: + - "3011:3011" + depends_on: [zappier-edge] + + sales-pricing: + <<: *node + image: node:22-alpine + working_dir: /app + volumes: + - ${MONOREPO:-..}/packages/zappier-sales-pricing:/app + command: ["node", "src/server.js"] + environment: + PORT: "3012" + ZAPPIER_ADMIN_URL: http://zappier-edge:3000 + ports: + - "3012:3012" + depends_on: [zappier-edge] + + accounting-export: + <<: *node + image: node:22-alpine + working_dir: /app + volumes: + - ${MONOREPO:-..}/packages/zappier-accounting-export:/app + command: ["node", "src/server.js"] + environment: + PORT: "3013" + ZAPPIER_ADMIN_URL: http://zappier-edge:3000 + ports: + - "3013:3013" + depends_on: [zappier-edge] + middleware: <<: *node build: diff --git a/packages/zapier-user-docs/02-signup-zappier-portal.md b/packages/zapier-user-docs/02-signup-zappier-portal.md index dff70fd..2d98c43 100644 --- a/packages/zapier-user-docs/02-signup-zappier-portal.md +++ b/packages/zapier-user-docs/02-signup-zappier-portal.md @@ -15,7 +15,7 @@ If your company already created an account (you received a key by email), sign u - **Dashboard** — usage this month, included credit, billable overage, prepaid balance. - **API & pricing** — live rate card (free / fixed / variable per operation, including batch). - **Invoices** — draft / issued / paid. -- **Billing** — reload $1–$10,000; prepaid is drawn down automatically. +- **Billing** — reload $1–$10,000; prepaid is drawn down automatically. Customer-service can add goodwill credits; sales can set a per-customer multiplier; accounting can export invoices to QuickBooks (IIF/CSV). - **Security** — optional 2FA. Regenerating the API key **invalidates the old one immediately**. Update the Zapier authentication field or every Zap step will 401. diff --git a/packages/zappier-accounting-export/README.md b/packages/zappier-accounting-export/README.md new file mode 100644 index 0000000..ec0439a --- /dev/null +++ b/packages/zappier-accounting-export/README.md @@ -0,0 +1,18 @@ +# zappier-accounting-export + +Accounting-department export of **invoices, billing, and payment history** to **QuickBooks IIF** and generic CSV (also usable in other ledgers). + +**Forgejo:** https://git.georgelambert.org/marchon/zappier-accounting-export +**Catalog:** https://zapier.georgelambert.org/packages/zappier-accounting-export/README.pdf + +Reads issued/paid invoices from zappier-edge (`ZAPPIER_ADMIN_URL`). + +```bash +PORT=3013 node src/server.js +curl 'http://127.0.0.1:3013/export/quickbooks.iif?period=2026-07' -o zappier.iif +curl 'http://127.0.0.1:3013/export/accounting.csv?period=2026-07' -o zappier.csv +``` + +Import the IIF in QuickBooks Desktop: **File → Utilities → Import → IIF**. The CSV is a flat invoice register for other systems. + +History is the invoice list on zappier-edge (draft / issued / paid) plus CS credit ledger and portal prepaid reloads. diff --git a/packages/zappier-accounting-export/package.json b/packages/zappier-accounting-export/package.json new file mode 100644 index 0000000..4165d1c --- /dev/null +++ b/packages/zappier-accounting-export/package.json @@ -0,0 +1,11 @@ +{ + "name": "zappier-accounting-export", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Export zappier invoices to QuickBooks IIF and accounting CSV", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/packages/zappier-accounting-export/src/server.js b/packages/zappier-accounting-export/src/server.js new file mode 100644 index 0000000..f19d025 --- /dev/null +++ b/packages/zappier-accounting-export/src/server.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** Accounting department: QuickBooks IIF + CSV from zappier-edge invoices. */ +import http from 'node:http'; + +const PORT = Number(process.env.PORT || 3013); +const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); +const KEY = process.env.ZAPPIER_ADMIN_KEY || 'admin-dev-key'; + +async function edge(pathname) { + const r = await fetch(`${EDGE}${pathname}`, { headers: { 'x-admin-key': KEY } }); + return r; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const send = (code, type, body) => { + res.writeHead(code, { 'content-type': type }); + res.end(body); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return send(200, 'application/json', JSON.stringify({ ok: true, role: 'zappier-accounting-export' })); + } + const period = url.searchParams.get('period'); + const q = period ? `?period=${encodeURIComponent(period)}` : ''; + if (req.method === 'GET' && url.pathname === '/export/quickbooks.iif') { + const r = await edge(`/admin/api/exports/quickbooks.iif${q}`); + return send(r.status, 'text/plain', await r.text()); + } + if (req.method === 'GET' && url.pathname === '/export/accounting.csv') { + const r = await edge(`/admin/api/exports/accounting.csv${q}`); + return send(r.status, 'text/csv', await r.text()); + } + send(404, 'application/json', JSON.stringify({ error: 'not found' })); + } catch (err) { + send(502, 'application/json', JSON.stringify({ error: err.message })); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`zappier-accounting-export http://127.0.0.1:${PORT}/\n`); +}); diff --git a/packages/zappier-accounting-export/test/health.test.js b/packages/zappier-accounting-export/test/health.test.js new file mode 100644 index 0000000..7d3def8 --- /dev/null +++ b/packages/zappier-accounting-export/test/health.test.js @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('accounting-export health', async () => { + const port = 18013; + const child = spawn(process.execPath, ['src/server.js'], { + cwd: root, + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 400)); + try { + const r = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal((await r.json()).role, 'zappier-accounting-export'); + } finally { + child.kill('SIGTERM'); + } +}); diff --git a/packages/zappier-customer-service/.gitignore b/packages/zappier-customer-service/.gitignore new file mode 100644 index 0000000..dd950c9 --- /dev/null +++ b/packages/zappier-customer-service/.gitignore @@ -0,0 +1,2 @@ +data/ +node_modules/ diff --git a/packages/zappier-customer-service/README.md b/packages/zappier-customer-service/README.md new file mode 100644 index 0000000..d8ee4e9 --- /dev/null +++ b/packages/zappier-customer-service/README.md @@ -0,0 +1,21 @@ +# zappier-customer-service + +Department API for **customer-service credit additions** (goodwill, make-goods). Prepaid balances stay on **zappier-edge**; this service is the CS front door and audit copy. + +**Forgejo:** https://git.georgelambert.org/marchon/zappier-customer-service +**Catalog:** https://zapier.georgelambert.org/packages/zappier-customer-service/README.pdf +**Plugs into:** zappier-edge admin (`x-admin-key`) at `ZAPPIER_ADMIN_URL` (default `http://127.0.0.1:3000`). + +```bash +PORT=3011 ZAPPIER_ADMIN_URL=http://127.0.0.1:3000 node src/server.js +curl -X POST http://127.0.0.1:3011/credits -H 'content-type: application/json' \ + -d '{"customerId":"cust_1","cents":500,"reason":"goodwill","agent":"cs-anna"}' +``` + +| Route | Job | +|-------|-----| +| `GET /health` | `{ ok, role }` | +| `POST /credits` | Add/subtract prepaid cents on the customer | +| `GET /credits?customerId=` | Ledger | + +Does not talk to NATS or Zapier cloud. diff --git a/packages/zappier-customer-service/package.json b/packages/zappier-customer-service/package.json new file mode 100644 index 0000000..8b7fc1a --- /dev/null +++ b/packages/zappier-customer-service/package.json @@ -0,0 +1,11 @@ +{ + "name": "zappier-customer-service", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Customer-service credits and notes; writes prepaid balances on zappier-edge", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/packages/zappier-customer-service/src/server.js b/packages/zappier-customer-service/src/server.js new file mode 100644 index 0000000..301a543 --- /dev/null +++ b/packages/zappier-customer-service/src/server.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Customer-service department API: goodwill credits, CS notes. + * Source of truth for balances remains zappier-edge admin. + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; + +const PORT = Number(process.env.PORT || 3011); +const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); +const KEY = process.env.ZAPPIER_ADMIN_KEY || 'admin-dev-key'; +const STORE = process.env.CS_STORE || path.join(process.cwd(), 'data', 'credits.json'); + +function load() { + try { + return JSON.parse(fs.readFileSync(STORE, 'utf8')); + } catch { + return { credits: [] }; + } +} +function save(data) { + fs.mkdirSync(path.dirname(STORE), { recursive: true }); + fs.writeFileSync(STORE, JSON.stringify(data, null, 2)); +} + +async function edge(pathname, { method = 'GET', body } = {}) { + const r = await fetch(`${EDGE}${pathname}`, { + method, + headers: { 'content-type': 'application/json', 'x-admin-key': KEY }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await r.text(); + try { + return { status: r.status, body: JSON.parse(text) }; + } catch { + return { status: r.status, body: text }; + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'zappier-customer-service' }); + } + if (req.method === 'POST' && url.pathname === '/credits') { + const chunks = []; + for await (const c of req) chunks.push(c); + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + const forwarded = await edge('/admin/api/credits', { method: 'POST', body: payload }); + const data = load(); + data.credits.unshift({ ...payload, at: new Date().toISOString(), edge: forwarded.status }); + save(data); + return json(forwarded.status, forwarded.body); + } + if (req.method === 'GET' && url.pathname === '/credits') { + const forwarded = await edge(`/admin/api/credits${url.search}`); + return json(forwarded.status, forwarded.body); + } + json(404, { error: 'not found' }); + } catch (err) { + json(502, { error: err.message }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`zappier-customer-service http://127.0.0.1:${PORT}/\n`); +}); diff --git a/packages/zappier-customer-service/test/health.test.js b/packages/zappier-customer-service/test/health.test.js new file mode 100644 index 0000000..26586c0 --- /dev/null +++ b/packages/zappier-customer-service/test/health.test.js @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.dirname(fileURLToPath(new URL('.', import.meta.url))); + +test('customer-service health', async () => { + const port = 18011; + const child = spawn(process.execPath, ['src/server.js'], { + cwd: path.join(root), + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 400)); + try { + const r = await fetch(`http://127.0.0.1:${port}/health`); + const body = await r.json(); + assert.equal(body.role, 'zappier-customer-service'); + } finally { + child.kill('SIGTERM'); + } +}); diff --git a/packages/zappier-sales-pricing/README.md b/packages/zappier-sales-pricing/README.md new file mode 100644 index 0000000..d5c2fbf --- /dev/null +++ b/packages/zappier-sales-pricing/README.md @@ -0,0 +1,14 @@ +# zappier-sales-pricing + +Sales-department API for **per-customer pricing** (tier + `multiplierOverride`). zappier-edge remains the rate-card store. + +**Forgejo:** https://git.georgelambert.org/marchon/zappier-sales-pricing +**Catalog:** https://zapier.georgelambert.org/packages/zappier-sales-pricing/README.pdf + +```bash +PORT=3012 node src/server.js +curl http://127.0.0.1:3012/quotes/cust_2 +curl -X PUT http://127.0.0.1:3012/customers/cust_2/pricing \ + -H 'content-type: application/json' \ + -d '{"multiplierOverride":0.4,"tierId":"business"}' +``` diff --git a/packages/zappier-sales-pricing/package.json b/packages/zappier-sales-pricing/package.json new file mode 100644 index 0000000..8fa941f --- /dev/null +++ b/packages/zappier-sales-pricing/package.json @@ -0,0 +1,11 @@ +{ + "name": "zappier-sales-pricing", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Sales-department per-customer pricing; writes multiplier/tier on zappier-edge", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/packages/zappier-sales-pricing/src/server.js b/packages/zappier-sales-pricing/src/server.js new file mode 100644 index 0000000..eddfb0b --- /dev/null +++ b/packages/zappier-sales-pricing/src/server.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** Sales department: per-customer multiplier / tier. Writes through zappier-edge. */ +import http from 'node:http'; + +const PORT = Number(process.env.PORT || 3012); +const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); +const KEY = process.env.ZAPPIER_ADMIN_KEY || 'admin-dev-key'; + +async function edge(pathname, { method = 'GET', body } = {}) { + const r = await fetch(`${EDGE}${pathname}`, { + method, + headers: { 'content-type': 'application/json', 'x-admin-key': KEY }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await r.text(); + try { + return { status: r.status, body: JSON.parse(text) }; + } catch { + return { status: r.status, body: text }; + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'zappier-sales-pricing' }); + } + const quote = url.pathname.match(/^\/quotes\/([^/]+)$/); + if (req.method === 'GET' && quote) { + const forwarded = await edge(`/admin/api/sales/quote/${quote[1]}`); + return json(forwarded.status, forwarded.body); + } + const price = url.pathname.match(/^\/customers\/([^/]+)\/pricing$/); + if (req.method === 'PUT' && price) { + const chunks = []; + for await (const c of req) chunks.push(c); + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + const forwarded = await edge(`/admin/api/customers/${price[1]}`, { method: 'PUT', body: payload }); + return json(forwarded.status, forwarded.body); + } + json(404, { error: 'not found' }); + } catch (err) { + json(502, { error: err.message }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`zappier-sales-pricing http://127.0.0.1:${PORT}/\n`); +}); diff --git a/packages/zappier-sales-pricing/test/health.test.js b/packages/zappier-sales-pricing/test/health.test.js new file mode 100644 index 0000000..43c2fef --- /dev/null +++ b/packages/zappier-sales-pricing/test/health.test.js @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('sales-pricing health', async () => { + const port = 18012; + const child = spawn(process.execPath, ['src/server.js'], { + cwd: root, + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 400)); + try { + const r = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal((await r.json()).role, 'zappier-sales-pricing'); + } finally { + child.kill('SIGTERM'); + } +}); diff --git a/packages/zappier/src/accounting-export.ts b/packages/zappier/src/accounting-export.ts new file mode 100644 index 0000000..fde79c9 --- /dev/null +++ b/packages/zappier/src/accounting-export.ts @@ -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'; +} diff --git a/packages/zappier/src/admin.ts b/packages/zappier/src/admin.ts index cae65b2..db11b2b 100644 --- a/packages/zappier/src/admin.ts +++ b/packages/zappier/src/admin.ts @@ -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(); @@ -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) => { diff --git a/packages/zappier/src/app.ts b/packages/zappier/src/app.ts index c6cc916..52b3160 100644 --- a/packages/zappier/src/app.ts +++ b/packages/zappier/src/app.ts @@ -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) { diff --git a/packages/zappier/src/credits.ts b/packages/zappier/src/credits.ts new file mode 100644 index 0000000..e5854b5 --- /dev/null +++ b/packages/zappier/src/credits.ts @@ -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 & { 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; + } +} diff --git a/packages/zappier/src/upstream.ts b/packages/zappier/src/upstream.ts new file mode 100644 index 0000000..f29616b --- /dev/null +++ b/packages/zappier/src/upstream.ts @@ -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 { + 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 = { 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).quote = res.locals.quote; + } + res.status(r.status).json(body); + return true; +} diff --git a/packages/zappier/tests/health-proxy-credits.test.ts b/packages/zappier/tests/health-proxy-credits.test.ts new file mode 100644 index 0000000..7018954 --- /dev/null +++ b/packages/zappier/tests/health-proxy-credits.test.ts @@ -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'); + }); +}); diff --git a/scripts/build-docs-site.py b/scripts/build-docs-site.py index 15e9267..e971916 100755 --- a/scripts/build-docs-site.py +++ b/scripts/build-docs-site.py @@ -71,6 +71,9 @@ SECTIONS = [ ("packages/zappier/docs/CUSTOMER-PORTAL.md", "Signup, 2FA, API keys, reloads"), ("packages/zappier/docs/ACCOUNTING.md", "Invoices, PO billing, reports"), ("packages/zappier/docs/DEVELOPER.md", "Developer reference"), + ("packages/zappier-customer-service/README.md", "Customer-service credits API"), + ("packages/zappier-sales-pricing/README.md", "Sales per-customer pricing API"), + ("packages/zappier-accounting-export/README.md", "QuickBooks / accounting export"), ], ), ( @@ -323,6 +326,9 @@ def main() -> None: "verae-nats-process", "docs-master", "verae-ops", + "zappier-customer-service", + "zappier-sales-pricing", + "zappier-accounting-export", ): pkg_root = ROOT / "packages" / pkg if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}: @@ -481,6 +487,9 @@ def main() -> None: ("overview", "High-level system overview"), ("verae-nats-process", "Template for a new NATS address"), ("verae-ops", "Install, Docker, Proxmox, VMs, dedicated hardware"), + ("zappier-customer-service", "CS credits"), + ("zappier-sales-pricing", "Sales per-customer pricing"), + ("zappier-accounting-export", "QuickBooks export"), ] git_lis = "".join( f'
  • {name} ' diff --git a/scripts/gen-module-docs.py b/scripts/gen-module-docs.py index 15ee1ac..5e91e63 100644 --- a/scripts/gen-module-docs.py +++ b/scripts/gen-module-docs.py @@ -40,6 +40,9 @@ REPO_READMES = [ "zapier-user-docs", "docs-master", "verae-nats-process", + "zappier-customer-service", + "zappier-sales-pricing", + "zappier-accounting-export", ] SKIP_PARTS = {"test", "tests", "node_modules", "dist"} diff --git a/scripts/push-module-repos.sh b/scripts/push-module-repos.sh index 52e23e4..040b71e 100755 --- a/scripts/push-module-repos.sh +++ b/scripts/push-module-repos.sh @@ -50,6 +50,9 @@ create verae-fleet "Service catalog, replica floors, monitor, restart, pause" create overview "High-level system overview, NATS cluster, uptime, docs index" create verae-nats-process "Template JetStream worker for a new verae.* address" create verae-ops "Install, Docker, Proxmox, VMs, dedicated hardware, service linking" +create zappier-customer-service "Customer-service prepaid credit adjustments" +create zappier-sales-pricing "Sales per-customer pricing" +create zappier-accounting-export "QuickBooks IIF and accounting CSV export" push_dir "$ROOT/packages/zappier" zappier-edge push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware @@ -66,5 +69,8 @@ push_dir "$ROOT/packages/verae-fleet" verae-fleet push_dir "$ROOT/packages/overview" overview push_dir "$ROOT/packages/verae-nats-process" verae-nats-process push_dir "$ROOT/packages/verae-ops" verae-ops +push_dir "$ROOT/packages/zappier-customer-service" zappier-customer-service +push_dir "$ROOT/packages/zappier-sales-pricing" zappier-sales-pricing +push_dir "$ROOT/packages/zappier-accounting-export" zappier-accounting-export echo ALL_MODULE_REPOS_PUSHED