Wire zappier-edge into the live stack and add billing department APIs
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
Fleet now spawns the real zappier and middleware processes. Metered timestamp/receipt/hash calls proxy to middleware when ZAPPIER_UPSTREAM is set. CS credits, sales per-customer pricing, and QuickBooks export are separate repos plugged into zappier-edge admin.
This commit is contained in:
parent
65bfa544b2
commit
a1a5b957fd
44 changed files with 822 additions and 18 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-accounting-export NATS
|
||||
|
||||
None.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-accounting-export
|
||||
|
||||
Exports zappier invoices to QuickBooks IIF and accounting CSV.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-customer-service NATS
|
||||
|
||||
None. HTTPS only to zappier-edge admin.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-customer-service
|
||||
|
||||
CS department API for prepaid credit additions. Writes `balanceCents` on zappier-edge.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-sales-pricing NATS
|
||||
|
||||
None.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-sales-pricing
|
||||
|
||||
Sales department per-customer tier and multiplier. Writes through zappier-edge `/admin/api/customers`.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 },
|
||||
|
|
|
|||
21
packages/verae-fleet/services/accounting-export.json
Normal file
21
packages/verae-fleet/services/accounting-export.json
Normal 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": [] }
|
||||
}
|
||||
22
packages/verae-fleet/services/customer-service.json
Normal file
22
packages/verae-fleet/services/customer-service.json
Normal 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."
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
21
packages/verae-fleet/services/sales-pricing.json
Normal file
21
packages/verae-fleet/services/sales-pricing.json
Normal 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": [] }
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
18
packages/zappier-accounting-export/README.md
Normal file
18
packages/zappier-accounting-export/README.md
Normal 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.
|
||||
11
packages/zappier-accounting-export/package.json
Normal file
11
packages/zappier-accounting-export/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
42
packages/zappier-accounting-export/src/server.js
Normal file
42
packages/zappier-accounting-export/src/server.js
Normal 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`);
|
||||
});
|
||||
23
packages/zappier-accounting-export/test/health.test.js
Normal file
23
packages/zappier-accounting-export/test/health.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
2
packages/zappier-customer-service/.gitignore
vendored
Normal file
2
packages/zappier-customer-service/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
data/
|
||||
node_modules/
|
||||
21
packages/zappier-customer-service/README.md
Normal file
21
packages/zappier-customer-service/README.md
Normal 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.
|
||||
11
packages/zappier-customer-service/package.json
Normal file
11
packages/zappier-customer-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
73
packages/zappier-customer-service/src/server.js
Normal file
73
packages/zappier-customer-service/src/server.js
Normal 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`);
|
||||
});
|
||||
24
packages/zappier-customer-service/test/health.test.js
Normal file
24
packages/zappier-customer-service/test/health.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
14
packages/zappier-sales-pricing/README.md
Normal file
14
packages/zappier-sales-pricing/README.md
Normal 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"}'
|
||||
```
|
||||
11
packages/zappier-sales-pricing/package.json
Normal file
11
packages/zappier-sales-pricing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
54
packages/zappier-sales-pricing/src/server.js
Normal file
54
packages/zappier-sales-pricing/src/server.js
Normal 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`);
|
||||
});
|
||||
23
packages/zappier-sales-pricing/test/health.test.js
Normal file
23
packages/zappier-sales-pricing/test/health.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
36
packages/zappier/src/accounting-export.ts
Normal file
36
packages/zappier/src/accounting-export.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Invoice } from './invoicing';
|
||||
|
||||
function iifDate(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')}/${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
/** QuickBooks IIF journal for issued/paid invoices (Accounts Receivable + Sales). */
|
||||
export function invoicesToQuickBooksIif(invoices: Invoice[]): string {
|
||||
const lines = [
|
||||
'!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM',
|
||||
'!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM',
|
||||
'!ENDTRNS',
|
||||
];
|
||||
for (const inv of invoices) {
|
||||
if (inv.status === 'draft') continue;
|
||||
const date = iifDate(inv.issuedAtMs || inv.paidAtMs || Date.now());
|
||||
const dollars = (inv.billableCents / 100).toFixed(2);
|
||||
const name = inv.customerId;
|
||||
lines.push(`TRNS\t\tINVOICE\t${date}\tAccounts Receivable\t${name}\t${dollars}\t${inv.id}`);
|
||||
lines.push(`SPL\t\tINVOICE\t${date}\tSales\t${name}\t-${dollars}\t${inv.id}`);
|
||||
lines.push('ENDTRNS');
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
export function invoicesToAccountingCsv(invoices: Invoice[]): string {
|
||||
const header = 'invoice_id,customer_id,period,status,billing_type,total_cents,credit_cents,billable_cents,po_number';
|
||||
const rows = invoices.map(
|
||||
(i) =>
|
||||
[i.id, i.customerId, i.period, i.status, i.billingType, i.totalCents, i.creditCents, i.billableCents, i.poNumber || ''].join(
|
||||
',',
|
||||
),
|
||||
);
|
||||
return [header, ...rows].join('\n') + '\n';
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import { PriceRule, PricingStore, TierConfig } from './pricing';
|
|||
import { PROJECT_ROOT } from './paths';
|
||||
import { billingRows, toCsv, usageTrend } from './reports';
|
||||
import { UsageRepo } from './usage';
|
||||
import { CreditLedger } from './credits';
|
||||
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
|
||||
|
||||
// Issued login tokens (in-memory; a restart simply requires logging in again).
|
||||
const sessions = new Map<string, number>();
|
||||
|
|
@ -139,6 +141,7 @@ export function adminRouter(
|
|||
store: PricingStore,
|
||||
customers: CustomerRepo,
|
||||
accounting: AccountingDeps,
|
||||
credits: CreditLedger = new CreditLedger(),
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -222,6 +225,70 @@ export function adminRouter(
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ---------------- customer service: credits ---------------- */
|
||||
|
||||
router.post('/credits', (req, res) => {
|
||||
const { customerId, cents, reason, agent } = req.body ?? {};
|
||||
if (typeof customerId !== 'string' || !Number.isFinite(Number(cents))) {
|
||||
res.status(400).json({ error: 'customerId and cents required' });
|
||||
return;
|
||||
}
|
||||
const customer = customers.list().find((c) => c.id === customerId);
|
||||
if (!customer) {
|
||||
res.status(404).json({ error: 'customer not found' });
|
||||
return;
|
||||
}
|
||||
const delta = Math.trunc(Number(cents));
|
||||
const rec = credits.add({
|
||||
customerId,
|
||||
cents: delta,
|
||||
reason: typeof reason === 'string' ? reason : 'credit adjustment',
|
||||
agent: typeof agent === 'string' ? agent : 'admin',
|
||||
});
|
||||
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
|
||||
res.status(201).json(rec);
|
||||
});
|
||||
|
||||
router.get('/credits', (req, res) => {
|
||||
const customerId = typeof req.query.customerId === 'string' ? req.query.customerId : undefined;
|
||||
res.json({ credits: credits.list(customerId) });
|
||||
});
|
||||
|
||||
router.get('/sales/quote/:id', (req, res) => {
|
||||
const customer = customers.list().find((c) => c.id === req.params.id);
|
||||
if (!customer) {
|
||||
res.status(404).json({ error: 'customer not found' });
|
||||
return;
|
||||
}
|
||||
const tier = store.getTiers().find((t) => t.id === customer.tierId);
|
||||
res.json({
|
||||
customerId: customer.id,
|
||||
name: customer.name,
|
||||
tierId: customer.tierId,
|
||||
multiplierOverride: customer.multiplierOverride ?? null,
|
||||
monthlyCreditCents: tier?.monthlyCreditCents ?? 0,
|
||||
listMultiplier: tier?.multiplier ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/exports/quickbooks.iif', (req, res) => {
|
||||
const period = typeof req.query.period === 'string' ? req.query.period : undefined;
|
||||
const invoices = accounting.invoices.list({ ...(period ? { period } : {}) });
|
||||
res
|
||||
.type('text/plain')
|
||||
.set('content-disposition', 'attachment; filename="zappier-quickbooks.iif"')
|
||||
.send(invoicesToQuickBooksIif(invoices));
|
||||
});
|
||||
|
||||
router.get('/exports/accounting.csv', (req, res) => {
|
||||
const period = typeof req.query.period === 'string' ? req.query.period : undefined;
|
||||
const invoices = accounting.invoices.list({ ...(period ? { period } : {}) });
|
||||
res
|
||||
.type('text/csv')
|
||||
.set('content-disposition', 'attachment; filename="zappier-accounting.csv"')
|
||||
.send(invoicesToAccountingCsv(invoices));
|
||||
});
|
||||
|
||||
/* ---------------- accounting: invoices ---------------- */
|
||||
|
||||
router.post('/invoices/generate', (req, res) => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { InMemorySessionRepo, SessionRepo } from './accounts';
|
|||
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
|
||||
import { PROJECT_ROOT } from './paths';
|
||||
import { PaymentClient, portalRouter } from './portal';
|
||||
import { proxyVerae } from './upstream';
|
||||
|
||||
export interface StoredItem {
|
||||
id: string;
|
||||
|
|
@ -143,6 +144,10 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
);
|
||||
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ ok: true, role: 'zappier-edge' });
|
||||
});
|
||||
|
||||
app.use('/v1', apiKeyAuth(customers));
|
||||
app.use(
|
||||
OpenApiValidator.middleware({
|
||||
|
|
@ -162,7 +167,8 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
|
||||
});
|
||||
|
||||
app.post('/v1/timestamp', meter('timestamp', usage, pricing), (req, res) => {
|
||||
app.post('/v1/timestamp', meter('timestamp', usage, pricing), async (req, res) => {
|
||||
if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return;
|
||||
const data = req.body?.data != null ? String(req.body.data) : '';
|
||||
const sha256 =
|
||||
(req.body?.sha256 && String(req.body.sha256).toLowerCase()) ||
|
||||
|
|
@ -184,7 +190,8 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
res.status(202).json({ jobId, sha256, existing: false, timestamp });
|
||||
});
|
||||
|
||||
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), (req, res) => {
|
||||
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), async (req, res) => {
|
||||
if (await proxyVerae(req, res, `/zapier/v1/receipts/${req.params.jobId}`)) return;
|
||||
const rec = jobIndex.get(String(req.params.jobId));
|
||||
if (!rec) {
|
||||
res.status(404).json({ error: 'Job not found' });
|
||||
|
|
@ -201,7 +208,8 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
});
|
||||
});
|
||||
|
||||
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), (req, res) => {
|
||||
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), async (req, res) => {
|
||||
if (await proxyVerae(req, res, `/zapier/v1/hashes/${req.params.sha256}`)) return;
|
||||
const sha256 = String(req.params.sha256 || '').toLowerCase();
|
||||
const rec = hashIndex.get(sha256);
|
||||
if (!rec) {
|
||||
|
|
|
|||
29
packages/zappier/src/credits.ts
Normal file
29
packages/zappier/src/credits.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export interface CreditAdjustment {
|
||||
id: string;
|
||||
customerId: string;
|
||||
cents: number;
|
||||
reason: string;
|
||||
agent: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export class CreditLedger {
|
||||
private rows: CreditAdjustment[] = [];
|
||||
|
||||
add(row: Omit<CreditAdjustment, 'id' | 'at'> & { id?: string; at?: string }): CreditAdjustment {
|
||||
const rec: CreditAdjustment = {
|
||||
id: row.id || `crd_${Date.now().toString(36)}`,
|
||||
customerId: row.customerId,
|
||||
cents: row.cents,
|
||||
reason: row.reason,
|
||||
agent: row.agent,
|
||||
at: row.at || new Date().toISOString(),
|
||||
};
|
||||
this.rows.unshift(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
list(customerId?: string): CreditAdjustment[] {
|
||||
return customerId ? this.rows.filter((r) => r.customerId === customerId) : this.rows;
|
||||
}
|
||||
}
|
||||
45
packages/zappier/src/upstream.ts
Normal file
45
packages/zappier/src/upstream.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Request, Response } from 'express';
|
||||
|
||||
export type UpstreamFetch = typeof fetch;
|
||||
|
||||
/**
|
||||
* After zappier meters the call, forward Verae operations to middleware.
|
||||
* When ZAPPIER_UPSTREAM is unset, callers keep the local mock.
|
||||
*/
|
||||
export async function proxyVerae(
|
||||
req: Request,
|
||||
res: Response,
|
||||
pathname: string,
|
||||
fetchImpl: UpstreamFetch = fetch,
|
||||
): Promise<boolean> {
|
||||
const base = (process.env.ZAPPIER_UPSTREAM || '').replace(/\/$/, '');
|
||||
if (!base) return false;
|
||||
const url = new URL(pathname, `${base}/`);
|
||||
for (const [k, v] of Object.entries(req.query)) {
|
||||
if (typeof v === 'string') url.searchParams.set(k, v);
|
||||
}
|
||||
const headers: Record<string, string> = { accept: 'application/json' };
|
||||
const key = req.header('x-api-key');
|
||||
if (key) headers['x-api-key'] = key;
|
||||
const auth = req.header('authorization');
|
||||
if (auth) headers.authorization = auth;
|
||||
const method = req.method.toUpperCase();
|
||||
const init: RequestInit = { method, headers };
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
headers['content-type'] = 'application/json';
|
||||
init.body = JSON.stringify(req.body ?? {});
|
||||
}
|
||||
const r = await fetchImpl(url.toString(), init);
|
||||
const text = await r.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
/* keep text */
|
||||
}
|
||||
if (body && typeof body === 'object' && !Array.isArray(body) && res.locals.quote) {
|
||||
(body as Record<string, unknown>).quote = res.locals.quote;
|
||||
}
|
||||
res.status(r.status).json(body);
|
||||
return true;
|
||||
}
|
||||
75
packages/zappier/tests/health-proxy-credits.test.ts
Normal file
75
packages/zappier/tests/health-proxy-credits.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import request from 'supertest';
|
||||
import { buildApp } from '../src/app';
|
||||
|
||||
const KEY = 'key-ada';
|
||||
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
|
||||
|
||||
describe('zappier-edge operational wiring', () => {
|
||||
it('GET /health is public', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.role).toBe('zappier-edge');
|
||||
});
|
||||
|
||||
it('proxies timestamp to middleware when ZAPPIER_UPSTREAM is set', async () => {
|
||||
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain('/zapier/v1/timestamp');
|
||||
return new Response(JSON.stringify({ jobId: 'mw-1', sha256: 'abc' }), {
|
||||
status: 202,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).post('/v1/timestamp').set('x-api-key', KEY).send({ data: 'hello' });
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.jobId).toBe('mw-1');
|
||||
expect(res.body.quote.totalCents).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
delete process.env.ZAPPIER_UPSTREAM;
|
||||
}
|
||||
});
|
||||
|
||||
it('customer service can add prepaid credits', async () => {
|
||||
const { app } = buildApp();
|
||||
const add = await request(app)
|
||||
.post('/admin/api/credits')
|
||||
.set(ADMIN)
|
||||
.send({ customerId: 'cust_1', cents: 500, reason: 'goodwill', agent: 'cs-anna' });
|
||||
expect(add.status).toBe(201);
|
||||
expect(add.body.cents).toBe(500);
|
||||
const list = await request(app).get('/admin/api/credits?customerId=cust_1').set(ADMIN);
|
||||
expect(list.body.credits[0].reason).toBe('goodwill');
|
||||
});
|
||||
|
||||
it('sales quote returns per-customer multiplier', async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).get('/admin/api/sales/quote/cust_2').set(ADMIN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tierId).toBe('pro');
|
||||
});
|
||||
|
||||
it('exports issued invoices as QuickBooks IIF', async () => {
|
||||
const { app, usage } = buildApp();
|
||||
usage.record({
|
||||
customerId: 'cust_2',
|
||||
endpointId: 'transform',
|
||||
cents: 400,
|
||||
metadataBytes: 0,
|
||||
attachmentBytes: 0,
|
||||
timestamp: new Date('2026-07-05T10:00:00Z'),
|
||||
});
|
||||
await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' });
|
||||
await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN);
|
||||
const iif = await request(app).get('/admin/api/exports/quickbooks.iif?period=2026-07').set(ADMIN);
|
||||
expect(iif.status).toBe(200);
|
||||
expect(iif.text).toContain('TRNS');
|
||||
expect(iif.text).toContain('INV-2026-07-0001');
|
||||
const csv = await request(app).get('/admin/api/exports/accounting.csv?period=2026-07').set(ADMIN);
|
||||
expect(csv.text).toContain('invoice_id');
|
||||
});
|
||||
});
|
||||
|
|
@ -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'<li><a href="https://git.georgelambert.org/marchon/{name}">{name}</a> '
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue