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