Add NATS account-balance SoT and statement review for customers, CS, and sales
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
Internal billing now uses verae.billing.* request-reply and pubs. zappier-account-balance tracks prepaid, credits, usage, and payments. Portal, admin, CS, and sales all review the same statement. Independent Forgejo repos stay split via push-module-repos.
This commit is contained in:
parent
a1a5b957fd
commit
ac38676645
135 changed files with 3078 additions and 130 deletions
|
|
@ -2,14 +2,19 @@
|
|||
|
||||
Master map of every service in the Zapier ↔ middleware ↔ chain ↔ WORM-archive path.
|
||||
|
||||
Zapier cloud **never** connects to NATS. Only middleware workers and archives do.
|
||||
Zapier cloud **never** connects to NATS. Internal services do: middleware workers, archives, account-balance, zappier-edge billing, CS, sales, and accounting.
|
||||
|
||||
## Who talks to whom (HTTPS vs NATS)
|
||||
|
||||
```text
|
||||
[Zapier Platform app] HTTPS [zappier-edge] HTTPS [middleware-http]
|
||||
|
|
||||
NATS JetStream (NS1 loopback :4222) |
|
||||
| | NATS verae.billing.* |
|
||||
| v |
|
||||
[customer portal] [account-balance] NATS JetStream
|
||||
[CS :3011] queue account-balance (NS1 loopback :4222)
|
||||
[sales :3012] ^ |
|
||||
[accounting :3013] | request-reply |
|
||||
+-------------------------------+
|
||||
+------------------+------------------+-------------+
|
||||
| | |
|
||||
[job-poller] [webhook-deliver] [archive-aggregator]
|
||||
|
|
@ -25,7 +30,11 @@ Zapier cloud **never** connects to NATS. Only middleware workers and archives do
|
|||
| Module | Repo (Forgejo) | Runtime | Listens | Sends |
|
||||
|--------|----------------|---------|---------|-------|
|
||||
| zapier-platform-app | `verae-zapier-app` | Zapier cloud | User Zap steps | HTTPS to zappier-edge |
|
||||
| zappier-edge | `zappier-edge` | Public HTTPS :3000 | Zapier, portal, admin | HTTPS to middleware |
|
||||
| zappier-edge | `zappier-edge` | Public HTTPS :3000 | Zapier, portal, admin | HTTPS to middleware; NATS billing pub |
|
||||
| account-balance | `zappier-account-balance` | :3010 + NATS | `verae.billing.*` | statement/adjust replies |
|
||||
| customer-service | `zappier-customer-service` | :3011 | CS staff HTTP | NATS balance.adjust / statement.get |
|
||||
| sales-pricing | `zappier-sales-pricing` | :3012 | Sales HTTP | NATS statement.get; HTTPS to edge for multipliers |
|
||||
| accounting-export | `zappier-accounting-export` | :3013 | Accounting HTTP | reads invoices; NATS statement |
|
||||
| middleware-http | `verae-middleware` | Public HTTPS :3100 | zappier-edge | NATS jobs.watch; HTTP to chain; wait on jobs.events |
|
||||
| job-poller | `verae-job-poller` | Worker | `verae.zapier.jobs.watch` | `verae.zapier.jobs.events`; HTTP GET chain status |
|
||||
| webhook-deliver | `verae-webhook-deliver` | Worker | `verae.zapier.webhooks.deliver` + events | HTTPS POST Zapier REST Hook |
|
||||
|
|
@ -49,6 +58,11 @@ Zapier cloud **never** connects to NATS. Only middleware workers and archives do
|
|||
| `verae.zapier.jobs.events` | JetStream events | `event` (`timestamp.completed\|failed\|timeout`), `tenantId`, `jobId`, `status`, `traceId`, `emittedAt` |
|
||||
| `verae.zapier.webhooks.deliver` | JetStream work queue | `hookId`, `tenantId`, `targetUrl`, `event`, `payload`, `attempt`, `traceId` |
|
||||
| `verae.zapier.usage` | optional | `tenantId`, `action`, `amount`, `at` |
|
||||
| `verae.billing.statement.get` | request-reply | `customerId` → prepaid, credits, usage, payments |
|
||||
| `verae.billing.balance.adjust` | request-reply | `customerId`, `cents`, `reason`, `agent` |
|
||||
| `verae.billing.usage.recorded` | pub | meter events from zappier-edge |
|
||||
| `verae.billing.payment.recorded` | pub | portal reload / invoice paid |
|
||||
| `verae.billing.credit.applied` | pub | CS goodwill |
|
||||
| `verae.archive.put` | JetStream | `sha256`, `tenantId`, `kind` (`publicMeta\|privateMeta\|file\|tree`), `record`, `traceId` |
|
||||
| `verae.archive.query` | pub to **all** archives | `correlationId`, `sha256`, `tenantId`, `kinds[]`, `traceId` |
|
||||
| `verae.archive.reply.<correlationId>` | replies | `archiveId`, `sha256`, `records[]`, `traceId` |
|
||||
|
|
|
|||
12
packages/docs-master/modules/zappier-account-balance/NATS.md
Normal file
12
packages/docs-master/modules/zappier-account-balance/NATS.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# NATS — zappier-account-balance
|
||||
|
||||
Queue group `account-balance`. This process is the source of truth for prepaid cents.
|
||||
|
||||
| Direction | Address | Kind | Body |
|
||||
|-----------|---------|------|------|
|
||||
| IN | `verae.billing.statement.get` | request-reply | `{ customerId }` → statement |
|
||||
| IN | `verae.billing.balance.get` | request-reply | `{ customerId }` → statement |
|
||||
| IN | `verae.billing.balance.adjust` | request-reply | `{ customerId, cents, reason, agent, kind }` |
|
||||
| IN | `verae.billing.usage.recorded` | pub | `{ customerId, endpointId, cents, at }` (debits prepaid) |
|
||||
|
||||
`payment.recorded` and `credit.applied` are fan-out events from publishers; prepaid mutations go through `balance.adjust`.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-account-balance
|
||||
|
||||
NATS source of truth for prepaid balances, credits, usage events, and payments.
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
# zappier-accounting-export NATS
|
||||
# NATS — zappier-accounting-export
|
||||
|
||||
None.
|
||||
| Direction | Address | Kind |
|
||||
|-----------|---------|------|
|
||||
| OUT | `verae.billing.statement.get` | request-reply (review) |
|
||||
|
||||
IIF/CSV export remains HTTPS to zappier-edge admin.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# zappier-accounting-export
|
||||
|
||||
Exports zappier invoices to QuickBooks IIF and accounting CSV.
|
||||
Accounting export of invoices to QuickBooks IIF and CSV, plus **review** of customer credits, balances, usage, and payments over NATS.
|
||||
|
||||
Forgejo: https://git.georgelambert.org/marchon/zappier-accounting-export
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
# zappier-customer-service NATS
|
||||
# NATS — zappier-customer-service
|
||||
|
||||
None. HTTPS only to zappier-edge admin.
|
||||
| Direction | Address | Kind |
|
||||
|-----------|---------|------|
|
||||
| OUT | `verae.billing.statement.get` | request-reply (review) |
|
||||
| OUT | `verae.billing.balance.adjust` | request-reply (credits) |
|
||||
|
||||
HTTP fallback: `ACCOUNT_BALANCE_URL` then zappier-edge admin. Zapier cloud never connects here.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
# zappier-customer-service
|
||||
|
||||
CS department API for prepaid credit additions. Writes `balanceCents` on zappier-edge.
|
||||
CS department API and UI for prepaid credit additions and **review** of credits, balances, usage, and payments.
|
||||
|
||||
Internal path: NATS `verae.billing.statement.get` / `verae.billing.balance.adjust` → account-balance. HTTP fallback to `:3010` then zappier-edge admin.
|
||||
|
||||
Forgejo: https://git.georgelambert.org/marchon/zappier-customer-service
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
# zappier-sales-pricing NATS
|
||||
# NATS — zappier-sales-pricing
|
||||
|
||||
None.
|
||||
| Direction | Address | Kind |
|
||||
|-----------|---------|------|
|
||||
| OUT | `verae.billing.statement.get` | request-reply (review) |
|
||||
|
||||
Pricing writes remain HTTPS to zappier-edge admin.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# zappier-sales-pricing
|
||||
|
||||
Sales department per-customer tier and multiplier. Writes through zappier-edge `/admin/api/customers`.
|
||||
Sales department API and UI for per-customer tier/multiplier and **review** of the same NATS statement as CS and the customer portal.
|
||||
|
||||
Forgejo: https://git.georgelambert.org/marchon/zappier-sales-pricing
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
# NATS — zappier-edge
|
||||
|
||||
This process **does not subscribe or publish** on NATS.
|
||||
Zapier cloud still never connects to NATS. The edge process **does** publish and request on the private billing bus.
|
||||
|
||||
| Direction | Address | Peer | Body |
|
||||
|-----------|---------|------|------|
|
||||
| IN HTTPS | `/v1/*` | Zapier Platform app | JSON + `x-api-key` |
|
||||
| OUT HTTPS | middleware `/zapier/v1/*` | verae-middleware | same tenant request |
|
||||
| OUT NATS | `verae.billing.usage.recorded` | account-balance | meter event |
|
||||
| OUT NATS | `verae.billing.payment.recorded` | account-balance / others | portal reload |
|
||||
| OUT NATS | `verae.billing.credit.applied` | account-balance / others | admin/CS credit |
|
||||
| OUT NATS | `verae.billing.statement.get` | account-balance | portal/admin review |
|
||||
| OUT NATS | `verae.billing.balance.adjust` | account-balance | prepaid mutation |
|
||||
|
||||
NATS is private to middleware workers and archives.
|
||||
Jobs, archive, and webhooks stay on middleware workers.
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ Verae Time proves a SHA-256 existed at a given time. Zapier lets customers regis
|
|||
| Plane | What | Network |
|
||||
|-------|------|---------|
|
||||
| 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 |
|
||||
| Commercial edge | `zappier-edge` portal, admin, `x-api-key`, Stripe meter | Public HTTPS; NATS billing pubs/requests |
|
||||
| Account balance | `zappier-account-balance` prepaid SoT | NATS `verae.billing.*` + HTTP `:3010` |
|
||||
| CS / sales / accounting | `zappier-customer-service`, `zappier-sales-pricing`, `zappier-accounting-export` | Private HTTP; NATS statement/adjust |
|
||||
| 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 |
|
||||
|
|
@ -22,6 +23,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 (`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.
|
||||
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`. Meter events, CS credits, and portal reloads go over NATS `verae.billing.*` to **account-balance** (source of truth). Customers review the same statement in the portal; CS (`:3011`) and sales (`:3012`) review it on their department UIs. Attached metadata and bulk-summary **leaves** are found later by broadcasting `verae.archive.query` to every archive/tree node.
|
||||
|
||||
See [diagrams](08-diagrams.md).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
|
|||
| **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; **proxies Verae calls to middleware** |
|
||||
| **zappier-account-balance** | `packages/zappier-account-balance` | NATS source of truth for prepaid balances |
|
||||
| **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 |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ All durable messaging for this product is **NATS JetStream**, not Zapier queues
|
|||
|
||||
## Cluster
|
||||
|
||||
Three `nats-server -js` nodes form the Verae cluster (routes between them, JetStream replication). Clients (middleware, poller, aggregator, WORM, tree nodes, and any new process cloned from `verae-nats-process`) connect with a **cluster URL list**, for example:
|
||||
Three `nats-server -js` nodes form the Verae cluster (routes between them, JetStream replication). Clients (middleware, poller, aggregator, WORM, tree nodes, account-balance, zappier-edge billing, CS/sales/accounting, and any new process cloned from `verae-nats-process`) connect with a **cluster URL list**, for example:
|
||||
|
||||
```text
|
||||
nats://127.0.0.1:4222,nats://127.0.0.1:4223,nats://127.0.0.1:4224
|
||||
|
|
@ -20,7 +20,7 @@ Today’s NS1 box (`NS1.GEORGELAMBERT.ORG`, `70.88.205.138`) already runs JetStr
|
|||
|
||||
| Allowed | Forbidden |
|
||||
|---------|-----------|
|
||||
| verae-middleware, fleet workers, WORM, tree nodes, `verae-nats-process` clones | Zapier cloud, browsers, zappier-edge |
|
||||
| verae-middleware, fleet workers, WORM, tree nodes, account-balance, zappier-edge billing, CS/sales/accounting, `verae-nats-process` clones | Zapier cloud, customer browsers (they use HTTPS portal/CS/sales UIs) |
|
||||
|
||||
## Address families already in use
|
||||
|
||||
|
|
@ -30,6 +30,9 @@ Today’s NS1 box (`NS1.GEORGELAMBERT.ORG`, `70.88.205.138`) already runs JetStr
|
|||
| `verae.zapier.jobs.events` | events | wait + webhooks |
|
||||
| `verae.zapier.webhooks.deliver` | work queue | HTTPS to Zapier REST Hook |
|
||||
| `verae.zapier.usage` | optional | metering |
|
||||
| `verae.billing.statement.get` | request-reply | portal, CS, sales, admin |
|
||||
| `verae.billing.balance.adjust` | request-reply | CS credits, portal reload |
|
||||
| `verae.billing.usage.recorded` | pub | zappier-edge meter |
|
||||
| `verae.archive.put` | JetStream | splitter / merkle builder |
|
||||
| `verae.archive.query` | **broadcast** (no queue group) | every WORM and tree node |
|
||||
| `verae.archive.reply.<correlationId>` | replies | **only on bloom hit** |
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ 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]
|
||||
CS[customer-service] --> E
|
||||
SA[sales-pricing] --> E
|
||||
AC[accounting-export QuickBooks] --> E
|
||||
Cust[customer portal] -->|HTTPS statement| E
|
||||
CS[customer-service] -->|NATS statement/adjust| N[NATS 3-node cluster]
|
||||
SA[sales-pricing] -->|NATS statement| N
|
||||
AC[accounting-export] -->|NATS statement| N
|
||||
E -->|NATS billing| N
|
||||
BAL[account-balance] -->|reply verae.billing.*| N
|
||||
E -->|HTTPS metered| M[verae-middleware]
|
||||
M -->|HTTPS| C[Verae chain]
|
||||
M -->|JetStream| N[NATS 3-node cluster]
|
||||
M -->|JetStream| N
|
||||
N --> P[job-poller]
|
||||
N --> W[webhook-deliver]
|
||||
N --> A[archive-aggregator]
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@
|
|||
"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 },
|
||||
"account-balance": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||
"customer-service": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||
"sales-pricing": { "min": 1, "max": 1, "keepFloor": true, "enabled": true },
|
||||
"accounting-export": { "min": 1, "max": 1, "keepFloor": true, "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 },
|
||||
|
|
|
|||
31
packages/verae-fleet/services/account-balance.json
Normal file
31
packages/verae-fleet/services/account-balance.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"id": "account-balance",
|
||||
"title": "Account balance (NATS billing SoT)",
|
||||
"kind": "http",
|
||||
"package": "zappier-account-balance",
|
||||
"role": "account-balance",
|
||||
"managed": true,
|
||||
"runtime": "HTTP :3010 + NATS verae.billing.*",
|
||||
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
|
||||
"ports": { "healthBase": 3010 },
|
||||
"spawn": {
|
||||
"cwd": "../zappier-account-balance",
|
||||
"command": "node",
|
||||
"args": ["src/server.js"]
|
||||
},
|
||||
"env": {
|
||||
"PORT": "3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222"
|
||||
},
|
||||
"nats": {
|
||||
"in": [
|
||||
"verae.billing.statement.get",
|
||||
"verae.billing.balance.get",
|
||||
"verae.billing.balance.adjust",
|
||||
"verae.billing.usage.recorded",
|
||||
"verae.billing.payment.recorded",
|
||||
"verae.billing.credit.applied"
|
||||
],
|
||||
"out": []
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,9 @@
|
|||
},
|
||||
"env": {
|
||||
"PORT": "3013",
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000"
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222"
|
||||
},
|
||||
"nats": { "in": [], "out": [] }
|
||||
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"id": "customer-service",
|
||||
"title": "Customer-service credits",
|
||||
"title": "Customer-service credits and review",
|
||||
"kind": "http",
|
||||
"package": "zappier-customer-service",
|
||||
"role": "customer-service",
|
||||
"managed": true,
|
||||
"runtime": "HTTP :3011 CS credits → zappier-edge",
|
||||
"runtime": "HTTP :3011 CS credits + NATS statement review",
|
||||
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
|
||||
"ports": { "healthBase": 3011 },
|
||||
"spawn": {
|
||||
|
|
@ -15,8 +15,13 @@
|
|||
},
|
||||
"env": {
|
||||
"PORT": "3011",
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000"
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222"
|
||||
},
|
||||
"nats": { "in": [], "out": [] },
|
||||
"notes": "Department API. Prepaid balances live on zappier-edge."
|
||||
"nats": {
|
||||
"in": [],
|
||||
"out": ["verae.billing.statement.get", "verae.billing.balance.adjust"]
|
||||
},
|
||||
"notes": "CS review and credits. Balances live on account-balance over NATS."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"package": "zappier-sales-pricing",
|
||||
"role": "sales-pricing",
|
||||
"managed": true,
|
||||
"runtime": "HTTP :3012 quotes → zappier-edge",
|
||||
"runtime": "HTTP :3012 quotes + NATS statement review",
|
||||
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
|
||||
"ports": { "healthBase": 3012 },
|
||||
"spawn": {
|
||||
|
|
@ -15,7 +15,9 @@
|
|||
},
|
||||
"env": {
|
||||
"PORT": "3012",
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000"
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222"
|
||||
},
|
||||
"nats": { "in": [], "out": [] }
|
||||
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,18 @@
|
|||
"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"
|
||||
"ACCOUNTING_EXPORT_URL": "http://127.0.0.1:3013",
|
||||
"NATS_URL": "nats://127.0.0.1:4222"
|
||||
},
|
||||
"nats": { "in": [], "out": [] },
|
||||
"notes": "Zapier x-api-key lands here. HTTPS to middleware only."
|
||||
"nats": {
|
||||
"in": [],
|
||||
"out": [
|
||||
"verae.billing.usage.recorded",
|
||||
"verae.billing.payment.recorded",
|
||||
"verae.billing.credit.applied",
|
||||
"verae.billing.statement.get",
|
||||
"verae.billing.balance.adjust"
|
||||
]
|
||||
},
|
||||
"notes": "Zapier x-api-key lands here. HTTPS to middleware. NATS billing to account-balance."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ describe('verae-fleet', () => {
|
|||
for (const need of [
|
||||
'nats',
|
||||
'zappier-edge',
|
||||
'account-balance',
|
||||
'customer-service',
|
||||
'sales-pricing',
|
||||
'accounting-export',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ 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; meters then **proxies** to middleware | `:3000` public HTTPS |
|
||||
| **customer-service** | CS credit additions | `:3011` |
|
||||
| **account-balance** | NATS prepaid balances (SoT) | `:3010` |
|
||||
| **customer-service** | CS credit additions + review | `:3011` |
|
||||
| **sales-pricing** | Per-customer sales quotes | `:3012` |
|
||||
| **accounting-export** | QuickBooks IIF / CSV | `:3013` |
|
||||
| **verae-middleware** | Zapier `/zapier/v1`, chain, NATS | `:3100` |
|
||||
|
|
@ -37,13 +38,19 @@ Check:
|
|||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:3000/health # zappier-edge (billing + users)
|
||||
curl -fsS http://127.0.0.1:3010/health # account-balance (NATS SoT)
|
||||
curl -fsS http://127.0.0.1:3011/health # customer-service
|
||||
curl -fsS http://127.0.0.1:3012/health # sales-pricing
|
||||
curl -fsS http://127.0.0.1:3100/health # middleware
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- Customer portal: http://127.0.0.1:3000/portal — sign up, copy `x-api-key`
|
||||
- Admin: http://127.0.0.1:3000/admin
|
||||
- Customer portal: http://127.0.0.1:3000/portal — sign up, copy `x-api-key`, **Statement** tab
|
||||
- Admin: http://127.0.0.1:3000/admin — **Statement** per customer
|
||||
- CS review: http://127.0.0.1:3011/
|
||||
- Sales review: http://127.0.0.1:3012/
|
||||
- Accounting review/export: http://127.0.0.1:3013/
|
||||
- API docs: http://127.0.0.1:3000/docs
|
||||
|
||||
Do **not** publish NATS `4222`.
|
||||
|
|
|
|||
|
|
@ -50,8 +50,24 @@ services:
|
|||
environment:
|
||||
PORT: "3000"
|
||||
ZAPPIER_UPSTREAM: http://middleware:3100
|
||||
NATS_URL: nats://nats1:4222
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on: [nats1, account-balance]
|
||||
|
||||
account-balance:
|
||||
<<: *node
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ${MONOREPO:-..}/packages/zappier-account-balance:/app
|
||||
command: ["node", "src/server.js"]
|
||||
environment:
|
||||
PORT: "3010"
|
||||
NATS_URL: nats://nats1:4222
|
||||
ports:
|
||||
- "3010:3010"
|
||||
depends_on: [nats1]
|
||||
|
||||
customer-service:
|
||||
<<: *node
|
||||
|
|
@ -63,9 +79,11 @@ services:
|
|||
environment:
|
||||
PORT: "3011"
|
||||
ZAPPIER_ADMIN_URL: http://zappier-edge:3000
|
||||
ACCOUNT_BALANCE_URL: http://account-balance:3010
|
||||
NATS_URL: nats://nats1:4222
|
||||
ports:
|
||||
- "3011:3011"
|
||||
depends_on: [zappier-edge]
|
||||
depends_on: [zappier-edge, account-balance, nats1]
|
||||
|
||||
sales-pricing:
|
||||
<<: *node
|
||||
|
|
@ -77,9 +95,11 @@ services:
|
|||
environment:
|
||||
PORT: "3012"
|
||||
ZAPPIER_ADMIN_URL: http://zappier-edge:3000
|
||||
ACCOUNT_BALANCE_URL: http://account-balance:3010
|
||||
NATS_URL: nats://nats1:4222
|
||||
ports:
|
||||
- "3012:3012"
|
||||
depends_on: [zappier-edge]
|
||||
depends_on: [zappier-edge, account-balance, nats1]
|
||||
|
||||
accounting-export:
|
||||
<<: *node
|
||||
|
|
@ -91,9 +111,11 @@ services:
|
|||
environment:
|
||||
PORT: "3013"
|
||||
ZAPPIER_ADMIN_URL: http://zappier-edge:3000
|
||||
ACCOUNT_BALANCE_URL: http://account-balance:3010
|
||||
NATS_URL: nats://nats1:4222
|
||||
ports:
|
||||
- "3013:3013"
|
||||
depends_on: [zappier-edge]
|
||||
depends_on: [zappier-edge, account-balance, nats1]
|
||||
|
||||
middleware:
|
||||
<<: *node
|
||||
|
|
|
|||
3
packages/zappier-account-balance/NATS.md
Normal file
3
packages/zappier-account-balance/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# zappier-account-balance NATS
|
||||
|
||||
See README. Queue `account-balance`. No public bind.
|
||||
31
packages/zappier-account-balance/README.md
Normal file
31
packages/zappier-account-balance/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# zappier-account-balance
|
||||
|
||||
Source of truth for **customer prepaid balances**. Internal traffic is **NATS request-reply**; HTTP is health plus a fallback.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/zappier-account-balance
|
||||
**Catalog:** https://zapier.georgelambert.org/packages/zappier-account-balance/README.pdf
|
||||
|
||||
Zapier cloud never connects here. zappier-edge, customer-service, sales-pricing, and accounting-export do.
|
||||
|
||||
## Subjects
|
||||
|
||||
| Subject | Kind | Who |
|
||||
|---------|------|-----|
|
||||
| `verae.billing.statement.get` | request-reply | portal, CS, sales, admin |
|
||||
| `verae.billing.balance.get` | request-reply | anyone internal |
|
||||
| `verae.billing.balance.adjust` | request-reply | CS credits, portal reload |
|
||||
| `verae.billing.usage.recorded` | pub | zappier-edge meter |
|
||||
| `verae.billing.payment.recorded` | pub | portal reload, invoice paid |
|
||||
| `verae.billing.credit.applied` | pub | CS |
|
||||
|
||||
Queue group: `account-balance`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
NATS_URL=nats://127.0.0.1:4222 PORT=3010 npm start
|
||||
curl http://127.0.0.1:3010/health
|
||||
curl http://127.0.0.1:3010/statement/cust_1
|
||||
```
|
||||
|
||||
Statement JSON: `{ customerId, prepaidCents, credits[], usage[], payments[] }`.
|
||||
46
packages/zappier-account-balance/package-lock.json
generated
Normal file
46
packages/zappier-account-balance/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "zappier-account-balance",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zappier-account-balance",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/nats": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
|
||||
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
|
||||
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"nkeys.js": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nkeys.js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
|
||||
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tweetnacl": "1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/zappier-account-balance/package.json
Normal file
14
packages/zappier-account-balance/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "zappier-account-balance",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "NATS source of truth for customer prepaid balances, credits, usage, payments",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
}
|
||||
89
packages/zappier-account-balance/src/books.js
Normal file
89
packages/zappier-account-balance/src/books.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Source of truth for prepaid balances, CS credits, usage, and payments.
|
||||
*/
|
||||
export class AccountBooks {
|
||||
constructor() {
|
||||
/** @type {Record<string, number>} */
|
||||
this.prepaid = {};
|
||||
this.credits = [];
|
||||
this.usage = [];
|
||||
this.payments = [];
|
||||
}
|
||||
|
||||
prepaidCents(customerId) {
|
||||
return this.prepaid[customerId] ?? 0;
|
||||
}
|
||||
|
||||
adjust({ customerId, cents, reason, agent, kind = 'credit' }) {
|
||||
const delta = Math.trunc(Number(cents) || 0);
|
||||
const next = this.prepaidCents(customerId) + delta;
|
||||
this.prepaid[customerId] = next;
|
||||
const row = {
|
||||
id: `adj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
customerId,
|
||||
cents: delta,
|
||||
reason: reason || kind,
|
||||
agent: agent || 'system',
|
||||
kind,
|
||||
at: new Date().toISOString(),
|
||||
prepaidCents: next,
|
||||
};
|
||||
if (kind === 'payment' || kind === 'reload') this.payments.unshift(row);
|
||||
else this.credits.unshift(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
recordUsage(entry) {
|
||||
const cents = Number(entry.cents) || 0;
|
||||
const customerId = entry.customerId;
|
||||
const next = this.prepaidCents(customerId) - cents;
|
||||
this.prepaid[customerId] = next;
|
||||
const row = {
|
||||
customerId,
|
||||
endpointId: entry.endpointId || 'unknown',
|
||||
cents,
|
||||
at: entry.at || new Date().toISOString(),
|
||||
prepaidCents: next,
|
||||
};
|
||||
this.usage.unshift(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
recordPayment(entry) {
|
||||
return this.adjust({
|
||||
customerId: entry.customerId,
|
||||
cents: Number(entry.cents) || 0,
|
||||
reason: entry.reason || 'payment',
|
||||
agent: entry.agent || 'payments',
|
||||
kind: 'payment',
|
||||
});
|
||||
}
|
||||
|
||||
statement(customerId) {
|
||||
const match = (rows) => rows.filter((r) => r.customerId === customerId).slice(0, 100);
|
||||
return {
|
||||
customerId,
|
||||
prepaidCents: this.prepaidCents(customerId),
|
||||
credits: match(this.credits),
|
||||
usage: match(this.usage),
|
||||
payments: match(this.payments),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function handle(subject, payload, books) {
|
||||
const p = payload || {};
|
||||
if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) {
|
||||
return books.statement(p.customerId);
|
||||
}
|
||||
if (subject.endsWith('balance.adjust') || subject.endsWith('credit.applied')) {
|
||||
return books.adjust(p);
|
||||
}
|
||||
if (subject.endsWith('usage.recorded')) {
|
||||
return books.recordUsage(p);
|
||||
}
|
||||
if (subject.endsWith('payment.recorded')) {
|
||||
return books.recordPayment(p);
|
||||
}
|
||||
throw new Error(`unknown billing subject ${subject}`);
|
||||
}
|
||||
92
packages/zappier-account-balance/src/server.js
Normal file
92
packages/zappier-account-balance/src/server.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env node
|
||||
import http from 'node:http';
|
||||
import { AccountBooks, handle } from './books.js';
|
||||
import { SUBJECTS } from './subjects.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || process.env.FLEET_HEALTH_PORT || 3010);
|
||||
const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0';
|
||||
const books = new AccountBooks();
|
||||
let natsOk = false;
|
||||
|
||||
async function startNats() {
|
||||
const url = process.env.NATS_URL;
|
||||
if (!url) return;
|
||||
const { connect, StringCodec } = await import('nats');
|
||||
const nc = await connect({ servers: url.split(','), name: 'zappier-account-balance' });
|
||||
const sc = StringCodec();
|
||||
const reply = async (sub, fn) => {
|
||||
for await (const m of sub) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(sc.decode(m.data) || '{}');
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const out = fn(payload);
|
||||
if (m.reply) m.respond(sc.encode(JSON.stringify(out)));
|
||||
}
|
||||
};
|
||||
reply(nc.subscribe(SUBJECTS.STATEMENT_GET, { queue: SUBJECTS.QUEUE }), (p) =>
|
||||
handle(SUBJECTS.STATEMENT_GET, p, books),
|
||||
);
|
||||
reply(nc.subscribe(SUBJECTS.BALANCE_GET, { queue: SUBJECTS.QUEUE }), (p) =>
|
||||
handle(SUBJECTS.BALANCE_GET, p, books),
|
||||
);
|
||||
reply(nc.subscribe(SUBJECTS.BALANCE_ADJUST, { queue: SUBJECTS.QUEUE }), (p) =>
|
||||
handle(SUBJECTS.BALANCE_ADJUST, p, books),
|
||||
);
|
||||
(async () => {
|
||||
for await (const m of nc.subscribe(SUBJECTS.USAGE_RECORDED)) {
|
||||
handle(SUBJECTS.USAGE_RECORDED, JSON.parse(sc.decode(m.data) || '{}'), books);
|
||||
}
|
||||
})();
|
||||
// payment.recorded / credit.applied are fan-out events. Prepaid mutations
|
||||
// go through balance.adjust so publishers can both pub and request-reply.
|
||||
natsOk = true;
|
||||
process.stdout.write(`account-balance nats ${url}\n`);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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-account-balance', nats: natsOk, subjects: SUBJECTS });
|
||||
}
|
||||
const st = url.pathname.match(/^\/statement\/([^/]+)$/);
|
||||
if (req.method === 'GET' && st) {
|
||||
return json(200, books.statement(st[1]));
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/adjust') {
|
||||
const body = await readBody(req);
|
||||
return json(200, books.adjust(body));
|
||||
}
|
||||
json(404, { error: 'not found' });
|
||||
} catch (err) {
|
||||
json(500, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, BIND, () => {
|
||||
process.stdout.write(`zappier-account-balance http://${BIND}:${PORT}/\n`);
|
||||
});
|
||||
startNats().catch((err) => {
|
||||
process.stderr.write(`nats optional: ${err.message}\n`);
|
||||
});
|
||||
10
packages/zappier-account-balance/src/subjects.js
Normal file
10
packages/zappier-account-balance/src/subjects.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** Internal billing bus. Zapier cloud never subscribes. */
|
||||
export const SUBJECTS = {
|
||||
BALANCE_GET: 'verae.billing.balance.get',
|
||||
BALANCE_ADJUST: 'verae.billing.balance.adjust',
|
||||
STATEMENT_GET: 'verae.billing.statement.get',
|
||||
USAGE_RECORDED: 'verae.billing.usage.recorded',
|
||||
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
|
||||
CREDIT_APPLIED: 'verae.billing.credit.applied',
|
||||
QUEUE: 'account-balance',
|
||||
};
|
||||
16
packages/zappier-account-balance/test/books.test.js
Normal file
16
packages/zappier-account-balance/test/books.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AccountBooks, handle } from '../src/books.js';
|
||||
import { SUBJECTS } from '../src/subjects.js';
|
||||
|
||||
test('adjust credits prepaid and statement lists credits usage payments', () => {
|
||||
const books = new AccountBooks();
|
||||
handle(SUBJECTS.BALANCE_ADJUST, { customerId: 'c1', cents: 500, reason: 'goodwill', agent: 'cs' }, books);
|
||||
handle(SUBJECTS.USAGE_RECORDED, { customerId: 'c1', endpointId: 'timestamp', cents: 4 }, books);
|
||||
handle(SUBJECTS.PAYMENT_RECORDED, { customerId: 'c1', cents: 1000, reason: 'reload' }, books);
|
||||
const st = handle(SUBJECTS.STATEMENT_GET, { customerId: 'c1' }, books);
|
||||
assert.equal(st.prepaidCents, 1496);
|
||||
assert.equal(st.credits[0].cents, 500);
|
||||
assert.equal(st.usage[0].endpointId, 'timestamp');
|
||||
assert.equal(st.payments[0].kind, 'payment');
|
||||
});
|
||||
30
packages/zappier-account-balance/test/health.test.js
Normal file
30
packages/zappier-account-balance/test/health.test.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
test('account-balance health and statement', async () => {
|
||||
const port = 18010;
|
||||
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 h = await (await fetch(`http://127.0.0.1:${port}/health`)).json();
|
||||
assert.equal(h.role, 'zappier-account-balance');
|
||||
await fetch(`http://127.0.0.1:${port}/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ customerId: 'c1', cents: 200, reason: 'test' }),
|
||||
});
|
||||
const st = await (await fetch(`http://127.0.0.1:${port}/statement/c1`)).json();
|
||||
assert.equal(st.prepaidCents, 200);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
|
@ -5,10 +5,13 @@ Accounting-department export of **invoices, billing, and payment history** to **
|
|||
**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`).
|
||||
Reads issued/paid invoices from zappier-edge (`ZAPPIER_ADMIN_URL`). Customer statements (credits, balances, usage, payments) come from NATS `verae.billing.statement.get`.
|
||||
|
||||
Staff UI: http://127.0.0.1:3013/
|
||||
|
||||
```bash
|
||||
PORT=3013 node src/server.js
|
||||
PORT=3013 NATS_URL=nats://127.0.0.1:4222 node src/server.js
|
||||
curl http://127.0.0.1:3013/review/cust_1
|
||||
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
|
||||
```
|
||||
|
|
|
|||
46
packages/zappier-accounting-export/package-lock.json
generated
Normal file
46
packages/zappier-accounting-export/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "zappier-accounting-export",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zappier-accounting-export",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/nats": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
|
||||
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
|
||||
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"nkeys.js": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nkeys.js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
|
||||
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tweetnacl": "1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,5 +7,8 @@
|
|||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
packages/zappier-accounting-export/public/index.html
Normal file
51
packages/zappier-accounting-export/public/index.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Accounting — customer review</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
|
||||
h1 { font-size: 1.3rem; }
|
||||
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
|
||||
input { padding: 0.4rem 0.55rem; }
|
||||
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
|
||||
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
|
||||
.muted { color: #6b7186; }
|
||||
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
|
||||
.stat { font-size: 1.4rem; font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Accounting</h1>
|
||||
<p class="muted">Review payments, usage, credits, and balances. Export invoices to QuickBooks IIF / CSV.</p>
|
||||
<div class="card">
|
||||
<label>Customer id</label>
|
||||
<input id="id" value="cust_1" />
|
||||
<button onclick="review()">Review</button>
|
||||
<p class="muted"><a href="/export/quickbooks.iif">QuickBooks IIF</a> · <a href="/export/accounting.csv">CSV</a></p>
|
||||
</div>
|
||||
<div id="out"></div>
|
||||
<script>
|
||||
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
|
||||
const rows = (list, cols) =>
|
||||
(list || [])
|
||||
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
|
||||
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
|
||||
async function review() {
|
||||
const id = document.getElementById('id').value.trim();
|
||||
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
|
||||
document.getElementById('out').innerHTML = `
|
||||
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
|
||||
<p class="muted">source ${st.source || 'unknown'}</p></div>
|
||||
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
packages/zappier-accounting-export/src/nats-billing.js
Normal file
17
packages/zappier-accounting-export/src/nats-billing.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const SUBJECTS = { STATEMENT_GET: 'verae.billing.statement.get' };
|
||||
|
||||
export async function billingRequest(subject, payload) {
|
||||
const url = process.env.NATS_URL;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const { connect, StringCodec } = await import('nats');
|
||||
const nc = await connect({ servers: url.split(','), name: 'zappier-accounting-export' });
|
||||
const sc = StringCodec();
|
||||
const m = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 2000 });
|
||||
const out = JSON.parse(sc.decode(m.data) || '{}');
|
||||
await nc.close();
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
/** Accounting department: QuickBooks IIF + CSV from zappier-edge invoices. */
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
|
||||
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';
|
||||
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
async function edge(pathname) {
|
||||
const r = await fetch(`${EDGE}${pathname}`, { headers: { 'x-admin-key': KEY } });
|
||||
|
|
@ -17,9 +23,22 @@ const server = http.createServer(async (req, res) => {
|
|||
res.writeHead(code, { 'content-type': type });
|
||||
res.end(body);
|
||||
};
|
||||
const json = (code, obj) => send(code, 'application/json', JSON.stringify(obj));
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return send(200, 'application/json', JSON.stringify({ ok: true, role: 'zappier-accounting-export' }));
|
||||
return json(200, { ok: true, role: 'zappier-accounting-export' });
|
||||
}
|
||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||
if (req.method === 'GET' && review) {
|
||||
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: review[1] });
|
||||
if (nats) return json(200, { ...nats, source: 'nats' });
|
||||
const r = await fetch(`${BOOKS}/statement/${review[1]}`);
|
||||
if (r.ok) return json(200, { ...(await r.json()), source: 'account-balance' });
|
||||
const e = await edge(`/admin/api/statement/${review[1]}`);
|
||||
return send(e.status, 'application/json', await e.text());
|
||||
}
|
||||
const period = url.searchParams.get('period');
|
||||
const q = period ? `?period=${encodeURIComponent(period)}` : '';
|
||||
|
|
@ -31,9 +50,9 @@ const server = http.createServer(async (req, res) => {
|
|||
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' }));
|
||||
json(404, { error: 'not found' });
|
||||
} catch (err) {
|
||||
send(502, 'application/json', JSON.stringify({ error: err.message }));
|
||||
json(502, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ test('accounting-export health', async () => {
|
|||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
assert.equal((await r.json()).role, 'zappier-accounting-export');
|
||||
const home = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(home.status, 200);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
Department API for **customer-service credit additions** and **review** of credits, balances, usage, and payments. Internally uses **NATS** `verae.billing.*` (account-balance). HTTP fallback to account-balance then zappier-edge.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/zappier-customer-service
|
||||
**Catalog:** https://zapier.georgelambert.org/packages/zappier-customer-service/README.pdf
|
||||
|
|
@ -14,8 +14,10 @@ curl -X POST http://127.0.0.1:3011/credits -H 'content-type: application/json' \
|
|||
|
||||
| Route | Job |
|
||||
|-------|-----|
|
||||
| `GET /` | Staff review UI |
|
||||
| `GET /health` | `{ ok, role }` |
|
||||
| `POST /credits` | Add/subtract prepaid cents on the customer |
|
||||
| `POST /credits` | Add/subtract prepaid cents (NATS `balance.adjust`) |
|
||||
| `GET /credits?customerId=` | Ledger |
|
||||
| `GET /review/:customerId` | Full statement (balance, credits, usage, payments) |
|
||||
|
||||
Does not talk to NATS or Zapier cloud.
|
||||
Talks to NATS `verae.billing.*` internally. Never talks to Zapier cloud.
|
||||
|
|
|
|||
46
packages/zappier-customer-service/package-lock.json
generated
Normal file
46
packages/zappier-customer-service/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "zappier-customer-service",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zappier-customer-service",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/nats": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
|
||||
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
|
||||
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"nkeys.js": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nkeys.js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
|
||||
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tweetnacl": "1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,5 +7,8 @@
|
|||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
packages/zappier-customer-service/public/index.html
Normal file
67
packages/zappier-customer-service/public/index.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Customer service — account review</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
|
||||
h1 { font-size: 1.3rem; }
|
||||
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
|
||||
input { padding: 0.4rem 0.55rem; }
|
||||
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
|
||||
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
|
||||
.muted { color: #6b7186; }
|
||||
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
|
||||
.stat { font-size: 1.4rem; font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Customer service</h1>
|
||||
<p class="muted">Review prepaid balance, credits, usage, and payments. Credits go to account-balance over NATS.</p>
|
||||
<div class="card">
|
||||
<label>Customer id</label>
|
||||
<input id="id" value="cust_1" />
|
||||
<button onclick="review()">Review</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Add credit</h3>
|
||||
<label>Cents</label><input id="cents" type="number" value="500" />
|
||||
<label>Reason</label><input id="reason" value="goodwill" />
|
||||
<label>Agent</label><input id="agent" value="cs" />
|
||||
<button onclick="credit()">Apply credit</button>
|
||||
</div>
|
||||
<div id="out"></div>
|
||||
<script>
|
||||
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
|
||||
const rows = (list, cols) =>
|
||||
(list || [])
|
||||
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
|
||||
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
|
||||
async function review() {
|
||||
const id = document.getElementById('id').value.trim();
|
||||
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
|
||||
document.getElementById('out').innerHTML = `
|
||||
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
|
||||
<p class="muted">source ${st.source || 'unknown'}</p></div>
|
||||
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
|
||||
}
|
||||
async function credit() {
|
||||
const body = {
|
||||
customerId: document.getElementById('id').value.trim(),
|
||||
cents: Number(document.getElementById('cents').value),
|
||||
reason: document.getElementById('reason').value,
|
||||
agent: document.getElementById('agent').value,
|
||||
};
|
||||
await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
await review();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
packages/zappier-customer-service/src/nats-billing.js
Normal file
20
packages/zappier-customer-service/src/nats-billing.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const SUBJECTS = {
|
||||
STATEMENT_GET: 'verae.billing.statement.get',
|
||||
BALANCE_ADJUST: 'verae.billing.balance.adjust',
|
||||
};
|
||||
|
||||
export async function billingRequest(subject, payload) {
|
||||
const url = process.env.NATS_URL;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const { connect, StringCodec } = await import('nats');
|
||||
const nc = await connect({ servers: url.split(','), name: 'zappier-customer-service' });
|
||||
const sc = StringCodec();
|
||||
const m = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 2000 });
|
||||
const out = JSON.parse(sc.decode(m.data) || '{}');
|
||||
await nc.close();
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +1,27 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Customer-service department API: goodwill credits, CS notes.
|
||||
* Source of truth for balances remains zappier-edge admin.
|
||||
* CS HTTP front door. Internally prefers NATS account-balance, then HTTP.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
const PORT = Number(process.env.PORT || 3011);
|
||||
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
|
||||
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 };
|
||||
}
|
||||
async function statement(customerId) {
|
||||
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId });
|
||||
if (nats) return { status: 200, body: { ...nats, source: 'nats' } };
|
||||
const r = await fetch(`${BOOKS}/statement/${customerId}`);
|
||||
if (r.ok) return { status: r.status, body: { ...(await r.json()), source: 'account-balance' } };
|
||||
const e = await fetch(`${EDGE}/admin/api/statement/${customerId}`, { headers: { 'x-admin-key': KEY } });
|
||||
return { status: e.status, body: { ...(await e.json().catch(() => ({}))), source: 'edge' } };
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
|
|
@ -45,22 +31,43 @@ const server = http.createServer(async (req, res) => {
|
|||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return json(200, { ok: true, role: 'zappier-customer-service' });
|
||||
return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
|
||||
}
|
||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||
if (req.method === 'GET' && review) {
|
||||
const out = await statement(review[1]);
|
||||
return json(out.status, out.body);
|
||||
}
|
||||
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);
|
||||
const nats = await billingRequest(SUBJECTS.BALANCE_ADJUST, { ...payload, kind: 'credit' });
|
||||
if (nats) return json(200, { ...nats, source: 'nats' });
|
||||
const r = await fetch(`${BOOKS}/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await r.json().catch(() => ({}));
|
||||
await fetch(`${EDGE}/admin/api/credits`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-admin-key': KEY },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
return json(r.status, body);
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/credits') {
|
||||
const forwarded = await edge(`/admin/api/credits${url.search}`);
|
||||
return json(forwarded.status, forwarded.body);
|
||||
const id = url.searchParams.get('customerId');
|
||||
if (!id) return json(400, { error: 'customerId required' });
|
||||
const out = await statement(id);
|
||||
return json(out.status, { credits: out.body.credits || [] });
|
||||
}
|
||||
json(404, { error: 'not found' });
|
||||
} catch (err) {
|
||||
|
|
@ -69,5 +76,5 @@ const server = http.createServer(async (req, res) => {
|
|||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
process.stdout.write(`zappier-customer-service http://127.0.0.1:${PORT}/\n`);
|
||||
process.stdout.write(`zappier-customer-service http://0.0.0.0:${PORT}/\n`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,45 @@ test('customer-service health', async () => {
|
|||
const r = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
const body = await r.json();
|
||||
assert.equal(body.role, 'zappier-customer-service');
|
||||
const home = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(home.status, 200);
|
||||
assert.match(await home.text(), /Customer service/);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
||||
test('customer-service review via account-balance HTTP', async () => {
|
||||
const booksPort = 18014;
|
||||
const csPort = 18015;
|
||||
const books = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: path.join(root, '..', 'zappier-account-balance'),
|
||||
env: { ...process.env, PORT: String(booksPort) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const cs = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: path.join(root),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(csPort),
|
||||
ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
try {
|
||||
const add = await fetch(`http://127.0.0.1:${csPort}/credits`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ customerId: 'c-review', cents: 700, reason: 'goodwill', agent: 'cs' }),
|
||||
});
|
||||
assert.equal(add.status, 200);
|
||||
const st = await (await fetch(`http://127.0.0.1:${csPort}/review/c-review`)).json();
|
||||
assert.equal(st.prepaidCents, 700);
|
||||
assert.equal(st.source, 'account-balance');
|
||||
assert.equal(st.credits[0].reason, 'goodwill');
|
||||
} finally {
|
||||
cs.kill('SIGTERM');
|
||||
books.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ Sales-department API for **per-customer pricing** (tier + `multiplierOverride`).
|
|||
**Forgejo:** https://git.georgelambert.org/marchon/zappier-sales-pricing
|
||||
**Catalog:** https://zapier.georgelambert.org/packages/zappier-sales-pricing/README.pdf
|
||||
|
||||
Internal reviews use NATS `verae.billing.statement.get` (account-balance).
|
||||
|
||||
Staff UI: http://127.0.0.1:3012/ — review credits, balances, usage, payments.
|
||||
|
||||
```bash
|
||||
PORT=3012 node src/server.js
|
||||
PORT=3012 NATS_URL=nats://127.0.0.1:4222 node src/server.js
|
||||
curl http://127.0.0.1:3012/review/cust_2
|
||||
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' \
|
||||
|
|
|
|||
46
packages/zappier-sales-pricing/package-lock.json
generated
Normal file
46
packages/zappier-sales-pricing/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "zappier-sales-pricing",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zappier-sales-pricing",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/nats": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
|
||||
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
|
||||
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"nkeys.js": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nkeys.js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
|
||||
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tweetnacl": "1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,5 +7,8 @@
|
|||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
packages/zappier-sales-pricing/public/index.html
Normal file
56
packages/zappier-sales-pricing/public/index.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sales — customer review</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
|
||||
h1 { font-size: 1.3rem; }
|
||||
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
|
||||
input { padding: 0.4rem 0.55rem; }
|
||||
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
|
||||
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
|
||||
.muted { color: #6b7186; }
|
||||
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
|
||||
.stat { font-size: 1.4rem; font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sales</h1>
|
||||
<p class="muted">Review customer credits, balances, usage, and payments (NATS account-balance). Pricing writes still go to zappier-edge.</p>
|
||||
<div class="card">
|
||||
<label>Customer id</label>
|
||||
<input id="id" value="cust_2" />
|
||||
<button onclick="review()">Review</button>
|
||||
<button onclick="quote()">Quote</button>
|
||||
</div>
|
||||
<div id="out"></div>
|
||||
<script>
|
||||
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
|
||||
const rows = (list, cols) =>
|
||||
(list || [])
|
||||
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
|
||||
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
|
||||
async function review() {
|
||||
const id = document.getElementById('id').value.trim();
|
||||
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
|
||||
document.getElementById('out').innerHTML = `
|
||||
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
|
||||
<p class="muted">source ${st.source || 'unknown'}</p></div>
|
||||
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
|
||||
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
|
||||
}
|
||||
async function quote() {
|
||||
const id = document.getElementById('id').value.trim();
|
||||
const q = await (await fetch('/quotes/' + encodeURIComponent(id))).json();
|
||||
document.getElementById('out').innerHTML = `<pre>${JSON.stringify(q, null, 2)}</pre>`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
packages/zappier-sales-pricing/src/nats-billing.js
Normal file
17
packages/zappier-sales-pricing/src/nats-billing.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const SUBJECTS = { STATEMENT_GET: 'verae.billing.statement.get' };
|
||||
|
||||
export async function billingRequest(subject, payload) {
|
||||
const url = process.env.NATS_URL;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const { connect, StringCodec } = await import('nats');
|
||||
const nc = await connect({ servers: url.split(','), name: 'zappier-sales-pricing' });
|
||||
const sc = StringCodec();
|
||||
const m = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 2000 });
|
||||
const out = JSON.parse(sc.decode(m.data) || '{}');
|
||||
await nc.close();
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
#!/usr/bin/env node
|
||||
/** Sales department: per-customer multiplier / tier. Writes through zappier-edge. */
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
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';
|
||||
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
|
||||
|
||||
async function edge(pathname, { method = 'GET', body } = {}) {
|
||||
const r = await fetch(`${EDGE}${pathname}`, {
|
||||
|
|
@ -27,9 +35,23 @@ const server = http.createServer(async (req, res) => {
|
|||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return json(200, { ok: true, role: 'zappier-sales-pricing' });
|
||||
}
|
||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||
if (req.method === 'GET' && review) {
|
||||
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: review[1] });
|
||||
if (nats) return json(200, { ...nats, source: 'nats' });
|
||||
const r = await fetch(`${BOOKS}/statement/${review[1]}`);
|
||||
if (r.ok) return json(200, { ...(await r.json()), source: 'account-balance' });
|
||||
const e = await edge(`/admin/api/statement/${review[1]}`);
|
||||
return json(e.status, e.body);
|
||||
}
|
||||
const quote = url.pathname.match(/^\/quotes\/([^/]+)$/);
|
||||
if (req.method === 'GET' && quote) {
|
||||
const forwarded = await edge(`/admin/api/sales/quote/${quote[1]}`);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,42 @@ test('sales-pricing health', async () => {
|
|||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
assert.equal((await r.json()).role, 'zappier-sales-pricing');
|
||||
const home = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(home.status, 200);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
||||
test('sales review via account-balance HTTP', async () => {
|
||||
const booksPort = 18016;
|
||||
const salesPort = 18017;
|
||||
const books = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: path.join(root, '..', 'zappier-account-balance'),
|
||||
env: { ...process.env, PORT: String(booksPort) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const sales = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(salesPort),
|
||||
ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${booksPort}/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ customerId: 'c-sales', cents: 300, reason: 'promo', agent: 'sales' }),
|
||||
});
|
||||
const st = await (await fetch(`http://127.0.0.1:${salesPort}/review/c-sales`)).json();
|
||||
assert.equal(st.prepaidCents, 300);
|
||||
assert.equal(st.source, 'account-balance');
|
||||
} finally {
|
||||
sales.kill('SIGTERM');
|
||||
books.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ async function load() {
|
|||
renderEndpoints();
|
||||
renderTiers();
|
||||
renderCustomers();
|
||||
renderStatement();
|
||||
renderInvoices();
|
||||
renderReports();
|
||||
renderSystem();
|
||||
|
|
@ -277,7 +278,8 @@ function renderCustomers() {
|
|||
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
|
||||
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
|
||||
<td><select data-customer="${c.id}" data-field="billingType">${btOptions(c.billingType)}</select></td>
|
||||
<td class="row-actions"><button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
|
||||
<td class="row-actions"><button class="btn ghost" onclick="reviewCustomer('${c.id}')">Statement</button>
|
||||
<button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
|
@ -308,6 +310,47 @@ async function saveCustomer(id) {
|
|||
await load();
|
||||
}
|
||||
|
||||
function renderStatement() {
|
||||
const options = customerOptions(state.customers[0]?.id || '');
|
||||
document.getElementById('statement').innerHTML = `
|
||||
<h2>Customer statement</h2>
|
||||
<p class="lede">Credits, prepaid balance, usage, and payments. Prefers NATS account-balance.</p>
|
||||
<div class="card">
|
||||
<div class="filterbar">
|
||||
<label><span>Customer</span><select id="stmt-customer">${options}</select></label>
|
||||
<button class="btn" onclick="reviewCustomer(document.getElementById('stmt-customer').value)">Load</button>
|
||||
</div>
|
||||
<div id="stmt-out"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function reviewCustomer(id) {
|
||||
if (!id) return;
|
||||
const st = await api(`/statement/${id}`);
|
||||
const row = (list, cols) =>
|
||||
(list || [])
|
||||
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
|
||||
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None.</td></tr>`;
|
||||
const html = `
|
||||
<p class="lede">${st.name || id} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
|
||||
<div class="card"><h3>Credits</h3><table><thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Payments</h3><table><thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
|
||||
const out = document.getElementById('stmt-out');
|
||||
if (out) out.innerHTML = html;
|
||||
else {
|
||||
document.getElementById('statement').innerHTML = `<h2>Customer statement</h2>${html}`;
|
||||
document.querySelectorAll('nav button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'statement'));
|
||||
document.querySelectorAll('main section').forEach((s) => (s.hidden = s.id !== 'statement'));
|
||||
}
|
||||
const sel = document.getElementById('stmt-customer');
|
||||
if (sel) sel.value = id;
|
||||
say(`Loaded statement for ${id}.`);
|
||||
}
|
||||
|
||||
async function addCustomer() {
|
||||
const name = document.getElementById('new-customer-name').value.trim();
|
||||
const tierId = document.getElementById('new-customer-tier').value;
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@
|
|||
<button data-tab="endpoints" class="active">▦ Rate card</button>
|
||||
<button data-tab="tiers">◈ Customer types</button>
|
||||
<button data-tab="customers">☺ Customers</button>
|
||||
<button data-tab="statement">☰ Statement</button>
|
||||
<button data-tab="invoices">▤ Invoices</button>
|
||||
<button data-tab="reports">↗ Reports</button>
|
||||
<button data-tab="system">⚙ System</button>
|
||||
|
|
@ -248,6 +249,7 @@
|
|||
<section id="endpoints"></section>
|
||||
<section id="tiers" hidden></section>
|
||||
<section id="customers" hidden></section>
|
||||
<section id="statement" hidden></section>
|
||||
<section id="invoices" hidden></section>
|
||||
<section id="reports" hidden></section>
|
||||
<section id="system" hidden></section>
|
||||
|
|
|
|||
32
packages/zappier/package-lock.json
generated
32
packages/zappier/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"dotenv": "^17.4.2",
|
||||
"express": "^4.19.2",
|
||||
"express-openapi-validator": "^5.3.0",
|
||||
"nats": "^2.29.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"stripe": "^16.0.0",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
|
|
@ -4397,6 +4398,19 @@
|
|||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nats": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
|
||||
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
|
||||
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"nkeys.js": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
|
|
@ -4420,6 +4434,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nkeys.js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
|
||||
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tweetnacl": "1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.94.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
|
||||
|
|
@ -5821,6 +5847,12 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"dotenv": "^17.4.2",
|
||||
"express": "^4.19.2",
|
||||
"express-openapi-validator": "^5.3.0",
|
||||
"nats": "^2.29.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"stripe": "^16.0.0",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const state = { me: null, usage: null, invoices: [], pricing: null };
|
||||
const state = { me: null, usage: null, invoices: [], pricing: null, statement: null };
|
||||
const TOKEN_KEY = 'zappier-portal-token';
|
||||
let authMode = 'login'; // 'login' | 'signup'
|
||||
|
||||
|
|
@ -118,8 +118,10 @@ async function load() {
|
|||
state.usage = await api('/usage');
|
||||
state.invoices = (await api('/invoices')).invoices;
|
||||
state.pricing = await api('/pricing');
|
||||
state.statement = await api('/statement');
|
||||
renderDashboard();
|
||||
renderInvoices();
|
||||
renderStatement();
|
||||
renderBilling();
|
||||
renderSecurity();
|
||||
renderDocs();
|
||||
|
|
@ -200,6 +202,34 @@ async function viewInvoice(id) {
|
|||
window.open(URL.createObjectURL(blob), '_blank');
|
||||
}
|
||||
|
||||
/* ---------------- statement ---------------- */
|
||||
|
||||
function renderStatement() {
|
||||
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
|
||||
const row = (list, cols) =>
|
||||
(list || [])
|
||||
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
|
||||
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None yet.</td></tr>`;
|
||||
document.getElementById('statement').innerHTML = `
|
||||
<h2>Statement</h2>
|
||||
<p class="lede">Prepaid balance, customer-service credits, metered usage, and payments. ${st.source === 'nats' ? 'Live from account-balance over NATS.' : 'From this portal’s ledger.'}</p>
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents || 0)}</div></div>
|
||||
<div class="stat"><div class="k">Credits</div><div class="v">${st.credits?.length || 0}</div></div>
|
||||
<div class="stat"><div class="k">Usage events</div><div class="v">${st.usage?.length || 0}</div></div>
|
||||
<div class="stat"><div class="k">Payments</div><div class="v">${st.payments?.length || 0}</div></div>
|
||||
</div>
|
||||
<div class="card"><h3>Credits</h3><table>
|
||||
<thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Usage</h3><table>
|
||||
<thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
|
||||
<div class="card"><h3>Payments</h3><table>
|
||||
<thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
/* ---------------- billing ---------------- */
|
||||
|
||||
function renderBilling() {
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@
|
|||
<nav>
|
||||
<button data-tab="dashboard" class="active">▤ Dashboard</button>
|
||||
<button data-tab="invoices">▦ Invoices</button>
|
||||
<button data-tab="statement">☰ Statement</button>
|
||||
<button data-tab="billing">↗ Billing</button>
|
||||
<button data-tab="security">◈ Security</button>
|
||||
<button data-tab="docs">▤ API & pricing</button>
|
||||
|
|
@ -258,6 +259,7 @@
|
|||
<main>
|
||||
<section id="dashboard"></section>
|
||||
<section id="invoices" hidden></section>
|
||||
<section id="statement" hidden></section>
|
||||
<section id="billing" hidden></section>
|
||||
<section id="security" hidden></section>
|
||||
<section id="docs" hidden></section>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { billingRows, toCsv, usageTrend } from './reports';
|
|||
import { UsageRepo } from './usage';
|
||||
import { CreditLedger } from './credits';
|
||||
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
|
||||
import { composeStatement } from './statement';
|
||||
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
|
||||
|
||||
// Issued login tokens (in-memory; a restart simply requires logging in again).
|
||||
const sessions = new Map<string, number>();
|
||||
|
|
@ -246,6 +248,14 @@ export function adminRouter(
|
|||
agent: typeof agent === 'string' ? agent : 'admin',
|
||||
});
|
||||
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
|
||||
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, rec);
|
||||
void natsAdjust({
|
||||
customerId,
|
||||
cents: delta,
|
||||
reason: rec.reason,
|
||||
agent: rec.agent,
|
||||
kind: 'credit',
|
||||
});
|
||||
res.status(201).json(rec);
|
||||
});
|
||||
|
||||
|
|
@ -254,6 +264,31 @@ export function adminRouter(
|
|||
res.json({ credits: credits.list(customerId) });
|
||||
});
|
||||
|
||||
router.get('/statement/:id', async (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 fromNats = await natsStatement(customer.id);
|
||||
if (fromNats) {
|
||||
res.json({ ...fromNats, name: customer.name, tierId: customer.tierId, source: 'nats' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
...composeStatement({
|
||||
customerId: customer.id,
|
||||
prepaidCents: customer.balanceCents ?? 0,
|
||||
credits: credits.list(customer.id),
|
||||
usage: accounting.usage.listFor(customer.id),
|
||||
invoices: accounting.invoices.list({ customerId: customer.id }),
|
||||
}),
|
||||
name: customer.name,
|
||||
tierId: customer.tierId,
|
||||
source: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/sales/quote/:id', (req, res) => {
|
||||
const customer = customers.list().find((c) => c.id === req.params.id);
|
||||
if (!customer) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
|
|||
import { PROJECT_ROOT } from './paths';
|
||||
import { PaymentClient, portalRouter } from './portal';
|
||||
import { proxyVerae } from './upstream';
|
||||
import { CreditLedger } from './credits';
|
||||
import { composeStatement } from './statement';
|
||||
import { BILLING_SUBJECTS, natsAdjust, natsPublish, natsStatement } from './billing-nats';
|
||||
|
||||
export interface StoredItem {
|
||||
id: string;
|
||||
|
|
@ -98,6 +101,9 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
},
|
||||
};
|
||||
const items: StoredItem[] = [];
|
||||
const credits = new CreditLedger();
|
||||
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) =>
|
||||
natsPublish(BILLING_SUBJECTS.USAGE_RECORDED, { ...e, at: new Date().toISOString() });
|
||||
const hashIndex = new Map<
|
||||
string,
|
||||
{ jobId: string; sha256: string; data?: string; timestamp: string }
|
||||
|
|
@ -114,7 +120,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
'/admin/api',
|
||||
adminLoginRouter(adminUsers),
|
||||
adminAuth(),
|
||||
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }),
|
||||
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits),
|
||||
);
|
||||
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
|
||||
|
||||
|
|
@ -140,6 +146,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
rateCard: () => pricingStore.getRateCard(),
|
||||
payments,
|
||||
qr,
|
||||
credits,
|
||||
}),
|
||||
);
|
||||
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
|
||||
|
|
@ -158,16 +165,16 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
}),
|
||||
);
|
||||
|
||||
app.get('/v1/status', meter('status', usage, pricing), (req, res) => {
|
||||
app.get('/v1/status', meter('status', usage, pricing, onUsage), (req, res) => {
|
||||
res.json({ status: 'ok', quote: res.locals.quote });
|
||||
});
|
||||
|
||||
app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => {
|
||||
app.post('/v1/transform', meter('transform', usage, pricing, onUsage), (req, res) => {
|
||||
const text = String(req.body?.text ?? '');
|
||||
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
|
||||
});
|
||||
|
||||
app.post('/v1/timestamp', meter('timestamp', usage, pricing), async (req, res) => {
|
||||
app.post('/v1/timestamp', meter('timestamp', usage, pricing, onUsage), async (req, res) => {
|
||||
if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return;
|
||||
const data = req.body?.data != null ? String(req.body.data) : '';
|
||||
const sha256 =
|
||||
|
|
@ -190,7 +197,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
res.status(202).json({ jobId, sha256, existing: false, timestamp });
|
||||
});
|
||||
|
||||
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), async (req, res) => {
|
||||
app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing, onUsage), 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) {
|
||||
|
|
@ -208,7 +215,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
});
|
||||
});
|
||||
|
||||
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), async (req, res) => {
|
||||
app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing, onUsage), 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);
|
||||
|
|
@ -219,7 +226,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
res.json({ exists: true, ...rec });
|
||||
});
|
||||
|
||||
app.post('/v1/add', meter('add', usage, pricing), (req, res) => {
|
||||
app.post('/v1/add', meter('add', usage, pricing, onUsage), (req, res) => {
|
||||
const number1 = Number(req.body?.number1);
|
||||
const number2 = Number(req.body?.number2);
|
||||
if (!Number.isFinite(number1) || !Number.isFinite(number2)) {
|
||||
|
|
@ -229,7 +236,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
res.json({ number1, number2, sum: number1 + number2, quote: res.locals.quote });
|
||||
});
|
||||
|
||||
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing), (req, res) => {
|
||||
app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing, onUsage), (req, res) => {
|
||||
const metadata = res.locals.parsedMetadata as Record<string, unknown>;
|
||||
const files = (req.files as Express.Multer.File[]) ?? [];
|
||||
const item: StoredItem = {
|
||||
|
|
@ -243,7 +250,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
res.json({ id: item.id, quote: res.locals.quote });
|
||||
});
|
||||
|
||||
app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => {
|
||||
app.get('/v1/storage', meter('storage-list', usage, pricing, onUsage), (req, res) => {
|
||||
res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
|
||||
});
|
||||
|
||||
|
|
|
|||
73
packages/zappier/src/billing-nats.ts
Normal file
73
packages/zappier/src/billing-nats.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/** Internal NATS billing bus. No-op when NATS_URL is unset (tests). */
|
||||
|
||||
export const BILLING_SUBJECTS = {
|
||||
STATEMENT_GET: 'verae.billing.statement.get',
|
||||
BALANCE_ADJUST: 'verae.billing.balance.adjust',
|
||||
USAGE_RECORDED: 'verae.billing.usage.recorded',
|
||||
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
|
||||
CREDIT_APPLIED: 'verae.billing.credit.applied',
|
||||
};
|
||||
|
||||
export type BillingStatement = {
|
||||
customerId: string;
|
||||
prepaidCents: number;
|
||||
credits: unknown[];
|
||||
usage: unknown[];
|
||||
payments: unknown[];
|
||||
};
|
||||
|
||||
type Nc = {
|
||||
request: (s: string, d: Uint8Array, o: { timeout: number }) => Promise<{ data: Uint8Array }>;
|
||||
publish: (s: string, d: Uint8Array) => void;
|
||||
};
|
||||
|
||||
let ncPromise: Promise<Nc | null> | null = null;
|
||||
|
||||
async function nc(): Promise<Nc | null> {
|
||||
const url = process.env.NATS_URL;
|
||||
if (!url) return null;
|
||||
if (!ncPromise) {
|
||||
ncPromise = (async () => {
|
||||
try {
|
||||
const nats = await import('nats');
|
||||
return (await nats.connect({ servers: url.split(','), name: 'zappier-edge' })) as unknown as Nc;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return ncPromise;
|
||||
}
|
||||
|
||||
function encode(obj: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(obj));
|
||||
}
|
||||
function decode(buf: Uint8Array): unknown {
|
||||
return JSON.parse(new TextDecoder().decode(buf) || '{}');
|
||||
}
|
||||
|
||||
export async function natsStatement(customerId: string): Promise<BillingStatement | null> {
|
||||
const c = await nc();
|
||||
if (!c) return null;
|
||||
const m = await c.request(BILLING_SUBJECTS.STATEMENT_GET, encode({ customerId }), { timeout: 2000 });
|
||||
return decode(m.data) as BillingStatement;
|
||||
}
|
||||
|
||||
export async function natsAdjust(payload: {
|
||||
customerId: string;
|
||||
cents: number;
|
||||
reason: string;
|
||||
agent: string;
|
||||
kind?: string;
|
||||
}): Promise<unknown | null> {
|
||||
const c = await nc();
|
||||
if (!c) return null;
|
||||
const m = await c.request(BILLING_SUBJECTS.BALANCE_ADJUST, encode(payload), { timeout: 2000 });
|
||||
return decode(m.data);
|
||||
}
|
||||
|
||||
export function natsPublish(subject: string, payload: unknown): void {
|
||||
void nc().then((c) => {
|
||||
if (c) c.publish(subject, encode(payload));
|
||||
});
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export function meter(
|
|||
endpointId: string,
|
||||
repo: UsageRepo,
|
||||
pricing: PricingContext,
|
||||
onRecord?: (entry: { customerId: string; endpointId: string; cents: number }) => void,
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const customer = req.customer;
|
||||
|
|
@ -34,14 +35,16 @@ export function meter(
|
|||
return;
|
||||
}
|
||||
|
||||
repo.record({
|
||||
const entry = {
|
||||
customerId: customer.id,
|
||||
endpointId,
|
||||
cents: quote.totalCents,
|
||||
metadataBytes,
|
||||
attachmentBytes,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
repo.record(entry);
|
||||
onRecord?.({ customerId: customer.id, endpointId, cents: quote.totalCents });
|
||||
res.locals.quote = quote;
|
||||
next();
|
||||
};
|
||||
|
|
|
|||
11
packages/zappier/src/nats-shim.d.ts
vendored
Normal file
11
packages/zappier/src/nats-shim.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
declare module 'nats' {
|
||||
export function connect(opts: unknown): Promise<{
|
||||
request(s: string, d: Uint8Array, o: { timeout: number }): Promise<{ data: Uint8Array }>;
|
||||
publish(s: string, d: Uint8Array): void;
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
export function StringCodec(): {
|
||||
encode(s: string): Uint8Array;
|
||||
decode(u: Uint8Array): string;
|
||||
};
|
||||
}
|
||||
|
|
@ -14,6 +14,9 @@ import { applyMonthlyCredit } from './billing/credit';
|
|||
import { InvoiceRepo } from './invoicing';
|
||||
import { RateCard, TierConfig } from './pricing';
|
||||
import { UsageRepo } from './usage';
|
||||
import { CreditLedger } from './credits';
|
||||
import { composeStatement } from './statement';
|
||||
import { BILLING_SUBJECTS, natsPublish, natsStatement, natsAdjust } from './billing-nats';
|
||||
|
||||
/**
|
||||
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
|
||||
|
|
@ -44,6 +47,7 @@ export interface PortalDeps {
|
|||
qr: (uri: string) => Promise<string>;
|
||||
sessionTtlMs?: number;
|
||||
now?: () => number;
|
||||
credits?: CreditLedger;
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
|
@ -198,6 +202,24 @@ export function portalRouter(deps: PortalDeps): Router {
|
|||
res.json({ tiers: deps.tiers(), rateCard: deps.rateCard() });
|
||||
});
|
||||
|
||||
router.get('/statement', async (req, res) => {
|
||||
const fromNats = await natsStatement(req.customer!.id);
|
||||
if (fromNats) {
|
||||
res.json({ ...fromNats, source: 'nats' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
...composeStatement({
|
||||
customerId: req.customer!.id,
|
||||
prepaidCents: req.customer!.balanceCents ?? 0,
|
||||
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),
|
||||
usage: deps.usage.listFor(req.customer!.id),
|
||||
invoices: deps.invoices.list({ customerId: req.customer!.id }),
|
||||
}),
|
||||
source: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/invoices/:id', (req, res) => {
|
||||
const invoice = deps.invoices.get(req.params.id);
|
||||
if (!invoice || invoice.customerId !== req.customer!.id) {
|
||||
|
|
@ -263,6 +285,20 @@ export function portalRouter(deps: PortalDeps): Router {
|
|||
balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents,
|
||||
};
|
||||
if (result.creditedCents > 0) save(deps, customer);
|
||||
if (result.creditedCents > 0) {
|
||||
natsPublish(BILLING_SUBJECTS.PAYMENT_RECORDED, {
|
||||
customerId: customer.id,
|
||||
cents: result.creditedCents,
|
||||
reason: 'reload',
|
||||
});
|
||||
void natsAdjust({
|
||||
customerId: customer.id,
|
||||
cents: result.creditedCents,
|
||||
reason: 'reload',
|
||||
agent: 'portal',
|
||||
kind: 'payment',
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
balanceCents: customer.balanceCents,
|
||||
mode: result.mode,
|
||||
|
|
|
|||
38
packages/zappier/src/statement.ts
Normal file
38
packages/zappier/src/statement.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { CreditAdjustment } from './credits';
|
||||
import { Invoice } from './invoicing';
|
||||
import { UsageEntry } from './usage';
|
||||
|
||||
export function composeStatement(args: {
|
||||
customerId: string;
|
||||
prepaidCents: number;
|
||||
credits: CreditAdjustment[];
|
||||
usage: UsageEntry[];
|
||||
invoices: Invoice[];
|
||||
}) {
|
||||
const payments = args.invoices
|
||||
.filter((i) => i.customerId === args.customerId && (i.status === 'paid' || i.status === 'issued'))
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
customerId: i.customerId,
|
||||
cents: i.billableCents,
|
||||
kind: i.status === 'paid' ? 'invoice-paid' : 'invoice-issued',
|
||||
reason: i.period,
|
||||
at: new Date(i.paidAtMs || i.issuedAtMs || Date.now()).toISOString(),
|
||||
}));
|
||||
return {
|
||||
customerId: args.customerId,
|
||||
prepaidCents: args.prepaidCents,
|
||||
credits: args.credits.filter((c) => c.customerId === args.customerId),
|
||||
usage: args.usage
|
||||
.filter((u) => u.customerId === args.customerId)
|
||||
.slice(-50)
|
||||
.reverse()
|
||||
.map((u) => ({
|
||||
customerId: u.customerId,
|
||||
endpointId: u.endpointId,
|
||||
cents: u.cents,
|
||||
at: u.timestamp.toISOString(),
|
||||
})),
|
||||
payments,
|
||||
};
|
||||
}
|
||||
|
|
@ -34,6 +34,18 @@ describe('zappier-edge operational wiring', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('customers CS and sales can review a statement', async () => {
|
||||
const { app } = buildApp();
|
||||
await request(app)
|
||||
.post('/admin/api/credits')
|
||||
.set(ADMIN)
|
||||
.send({ customerId: 'cust_1', cents: 250, reason: 'goodwill', agent: 'cs' });
|
||||
const st = await request(app).get('/admin/api/statement/cust_1').set(ADMIN);
|
||||
expect(st.status).toBe(200);
|
||||
expect(st.body.prepaidCents).toBeGreaterThanOrEqual(250);
|
||||
expect(st.body.credits.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('customer service can add prepaid credits', async () => {
|
||||
const { app } = buildApp();
|
||||
const add = await request(app)
|
||||
|
|
|
|||
|
|
@ -279,6 +279,12 @@ describe('portal billing', () => {
|
|||
expect(res.body.mode).toBe('dev');
|
||||
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
|
||||
expect(me.body.balanceCents).toBe(2500);
|
||||
const st = await request(app).get('/portal/api/statement').set('authorization', `Bearer ${token}`);
|
||||
expect(st.status).toBe(200);
|
||||
expect(st.body.prepaidCents).toBe(2500);
|
||||
expect(Array.isArray(st.body.credits)).toBe(true);
|
||||
expect(Array.isArray(st.body.usage)).toBe(true);
|
||||
expect(Array.isArray(st.body.payments)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects out-of-range reload amounts', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue