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

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

View file

@ -0,0 +1,3 @@
# zappier-accounting-export NATS
None.

View file

@ -0,0 +1,3 @@
# zappier-accounting-export
Exports zappier invoices to QuickBooks IIF and accounting CSV.

View file

@ -0,0 +1,3 @@
# zappier-customer-service NATS
None. HTTPS only to zappier-edge admin.

View file

@ -0,0 +1,3 @@
# zappier-customer-service
CS department API for prepaid credit additions. Writes `balanceCents` on zappier-edge.

View file

@ -0,0 +1,3 @@
# zappier-sales-pricing NATS
None.

View file

@ -0,0 +1,3 @@
# zappier-sales-pricing
Sales department per-customer tier and multiplier. Writes through zappier-edge `/admin/api/customers`.

View file

@ -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).

View file

@ -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) |

View file

@ -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]

View file

@ -9,6 +9,12 @@
<rect x="200" y="80" width="140" height="56" rx="8" fill="#0f6e56"/>
<text x="270" y="104" fill="#fff">zappier-edge</text>
<text x="270" y="120" fill="#d4efe6" font-size="10">HTTPS :3000 meter</text>
<rect x="175" y="148" width="62" height="36" rx="6" fill="#fff" stroke="#0f6e56"/>
<text x="206" y="170" fill="#12202c" font-size="8">CS credits</text>
<rect x="242" y="148" width="62" height="36" rx="6" fill="#fff" stroke="#0f6e56"/>
<text x="273" y="170" fill="#12202c" font-size="8">Sales $</text>
<rect x="309" y="148" width="62" height="36" rx="6" fill="#fff" stroke="#0f6e56"/>
<text x="340" y="170" fill="#12202c" font-size="8">QBooks</text>
<rect x="380" y="80" width="160" height="56" rx="8" fill="#0b4f8a"/>
<text x="460" y="104" fill="#fff">verae-middleware</text>
<text x="460" y="120" fill="#d9e2e8" font-size="10">HTTPS :3100 wait</text>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

@ -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 },

View file

@ -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": [] }
}

View file

@ -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."
}

View file

@ -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"]

View file

@ -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": [] }
}

View file

@ -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."
}

View file

@ -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 = {

View file

@ -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',

View file

@ -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 |

View file

@ -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:

View file

@ -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.

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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`);
});

View file

@ -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');
}
});

View file

@ -0,0 +1,2 @@
data/
node_modules/

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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`);
});

View file

@ -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');
}
});

View file

@ -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"}'
```

View file

@ -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"
}
}

View file

@ -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`);
});

View file

@ -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');
}
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,75 @@
import request from 'supertest';
import { buildApp } from '../src/app';
const KEY = 'key-ada';
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
describe('zappier-edge operational wiring', () => {
it('GET /health is public', async () => {
const { app } = buildApp();
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.role).toBe('zappier-edge');
});
it('proxies timestamp to middleware when ZAPPIER_UPSTREAM is set', async () => {
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
const orig = globalThis.fetch;
globalThis.fetch = jest.fn(async (url: string | URL) => {
expect(String(url)).toContain('/zapier/v1/timestamp');
return new Response(JSON.stringify({ jobId: 'mw-1', sha256: 'abc' }), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch;
try {
const { app } = buildApp();
const res = await request(app).post('/v1/timestamp').set('x-api-key', KEY).send({ data: 'hello' });
expect(res.status).toBe(202);
expect(res.body.jobId).toBe('mw-1');
expect(res.body.quote.totalCents).toBeGreaterThanOrEqual(0);
} finally {
globalThis.fetch = orig;
delete process.env.ZAPPIER_UPSTREAM;
}
});
it('customer service can add prepaid credits', async () => {
const { app } = buildApp();
const add = await request(app)
.post('/admin/api/credits')
.set(ADMIN)
.send({ customerId: 'cust_1', cents: 500, reason: 'goodwill', agent: 'cs-anna' });
expect(add.status).toBe(201);
expect(add.body.cents).toBe(500);
const list = await request(app).get('/admin/api/credits?customerId=cust_1').set(ADMIN);
expect(list.body.credits[0].reason).toBe('goodwill');
});
it('sales quote returns per-customer multiplier', async () => {
const { app } = buildApp();
const res = await request(app).get('/admin/api/sales/quote/cust_2').set(ADMIN);
expect(res.status).toBe(200);
expect(res.body.tierId).toBe('pro');
});
it('exports issued invoices as QuickBooks IIF', async () => {
const { app, usage } = buildApp();
usage.record({
customerId: 'cust_2',
endpointId: 'transform',
cents: 400,
metadataBytes: 0,
attachmentBytes: 0,
timestamp: new Date('2026-07-05T10:00:00Z'),
});
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
const iif = await request(app).get('/admin/api/exports/quickbooks.iif?period=2026-07').set(ADMIN);
expect(iif.status).toBe(200);
expect(iif.text).toContain('TRNS');
expect(iif.text).toContain('INV-2026-07-0001');
const csv = await request(app).get('/admin/api/exports/accounting.csv?period=2026-07').set(ADMIN);
expect(csv.text).toContain('invoice_id');
});
});