Add customer names on the ledger, staff session login, and exclusive jobs.events.
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
Account-balance stores display names and looks up by name. Edge writes names on customer create/edit; staff UIs join from edge when needed. New verae-staff-session issues a host cookie; department HTML redirects when STAFF_AUTH=1. JOBS_EVENTS_EXCLUSIVE lets jobs-events own the durable consumer. Catalog index is cards; disabled fleet machines are grey.
This commit is contained in:
parent
cb07f5b321
commit
9cc0018708
38 changed files with 533 additions and 68 deletions
|
|
@ -33,6 +33,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
|
|||
| **verae-zapier-simulator** | `packages/verae-zapier-simulator` | Trace console before `zapier-platform push` |
|
||||
| **zapier-user-docs** | `packages/zapier-user-docs` | Customer signup → register → lookup |
|
||||
| **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF |
|
||||
| **verae-staff-session** | `packages/verae-staff-session` | Shared staff cookie login for department HTML |
|
||||
| **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` |
|
||||
| **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services |
|
||||
|
||||
|
|
|
|||
25
packages/verae-access-staff/src/names.js
Normal file
25
packages/verae-access-staff/src/names.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** Join ledger ids to zappier-edge customer display names. */
|
||||
export async function withCustomerName(st, idOrName, edge, key) {
|
||||
const out = { ...(st || {}) };
|
||||
if (out.name && out.customerId) return out;
|
||||
try {
|
||||
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||
headers: { 'x-admin-key': key },
|
||||
});
|
||||
const { customers } = await r.json();
|
||||
const want = String(idOrName || out.customerId || '').toLowerCase();
|
||||
const c = (customers || []).find(
|
||||
(x) =>
|
||||
x.id === idOrName ||
|
||||
x.id === out.customerId ||
|
||||
String(x.name || '').toLowerCase() === want,
|
||||
);
|
||||
if (c) {
|
||||
out.name = c.name;
|
||||
out.customerId = c.id;
|
||||
}
|
||||
} catch {
|
||||
/* edge optional */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -4,11 +4,14 @@ import fs from 'node:fs';
|
|||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { withCustomerName } from './names.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
const PORT = Number(process.env.PORT || 3025);
|
||||
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
|
||||
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 PLANE = 'staff';
|
||||
|
||||
async function check(subject, extra = {}) {
|
||||
|
|
@ -28,6 +31,15 @@ const server = http.createServer(async (req, res) => {
|
|||
};
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
if (!chk || !chk.ok) {
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
return;
|
||||
|
|
@ -37,10 +49,12 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||
if (req.method === 'GET' && review) {
|
||||
const gate = await check('verae.billing.statement.get', { principal: review[1] });
|
||||
const id = decodeURIComponent(review[1]);
|
||||
const gate = await check('verae.billing.statement.get', { principal: id });
|
||||
if (!gate.allow) return json(403, gate);
|
||||
const r = await fetch(`${BOOKS}/statement/${review[1]}`);
|
||||
return json(r.status, { ...(await r.json()), plane: PLANE, source: 'account-balance' });
|
||||
const r = await fetch(`${BOOKS}/statement/${encodeURIComponent(id)}`);
|
||||
const body = await withCustomerName({ ...(await r.json()), plane: PLANE, source: 'account-balance' }, id, EDGE, KEY);
|
||||
return json(r.status, body);
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/credits') {
|
||||
const chunks = [];
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
.card.operational { background:var(--ok-bg); border-color:#c8efd4; }
|
||||
.card.degraded { background:var(--warn-bg); border-color:#ffe08a; }
|
||||
.card.down { background:var(--err-bg); border-color:#f3c0c0; }
|
||||
.card.disabled { background:#f1f5f9; border-color:#e5e7f0; }
|
||||
.card h3 { margin:0 0 .25rem; font-size:14px; }
|
||||
.muted { color:var(--muted); font-size:12px; }
|
||||
.pill { font:700 10px system-ui; letter-spacing:.06em; text-transform:uppercase; padding:.12rem .4rem; border-radius:999px; }
|
||||
|
|
@ -258,8 +259,8 @@ function spark(r) {
|
|||
<div class="muted">RTT ms ${r.minMs} / ${r.avgMs} / ${r.p50Ms} / ${r.p90Ms} n=${r.count}</div>`;
|
||||
}
|
||||
function pill(health) {
|
||||
const label = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : 'not operational';
|
||||
const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : 'bad';
|
||||
const label = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : health === 'disabled' ? 'disabled' : 'not operational';
|
||||
const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : health === 'disabled' ? '' : 'bad';
|
||||
return `<span class="pill ${cls}">${label}</span>`;
|
||||
}
|
||||
function moreMenu(items) {
|
||||
|
|
@ -303,7 +304,7 @@ async function drawFleet() {
|
|||
$('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '') + ' · HTTP 0.0.0.0:3850';
|
||||
const machines = s.machines || [];
|
||||
$('machines').innerHTML = machines.length ? machines.map((m) => {
|
||||
const health = !m.enabled ? 'down' : m.running > 0 ? 'operational' : 'degraded';
|
||||
const health = !m.enabled ? 'disabled' : m.running > 0 ? 'operational' : 'degraded';
|
||||
return `<article class="card ${health}">
|
||||
<h3>${m.id} ${pill(health)}</h3>
|
||||
<div class="muted">${m.title || ''}</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# NATS — verae-jobs-events
|
||||
|
||||
IN `verae.zapier.jobs.events` queue `jobs-events`.
|
||||
|
||||
When `JOBS_EVENTS_EXCLUSIVE=1`, this process is the durable JetStream consumer `jobs-events-exclusive` on `ZAPIER_EVENTS` and republishes to `verae.internal.jobs.events`. Middleware must set the same env so it does not also consume `verae.zapier.jobs.events`.
|
||||
|
|
|
|||
|
|
@ -14,14 +14,40 @@ async function startNats() {
|
|||
const { connect, StringCodec } = await import('nats');
|
||||
const nc = await connect({ servers: url.split(','), name: 'verae-jobs-events' });
|
||||
const sc = StringCodec();
|
||||
for await (const m of nc.subscribe(SUBJECTS.IN, { queue: SUBJECTS.QUEUE })) {
|
||||
const exclusive = process.env.JOBS_EVENTS_EXCLUSIVE === '1';
|
||||
const deliver = async (m) => {
|
||||
processed += 1;
|
||||
try {
|
||||
last = JSON.parse(sc.decode(m.data) || '{}');
|
||||
} catch {
|
||||
last = {};
|
||||
}
|
||||
if (m.reply) m.respond(sc.encode(JSON.stringify({ ok: true, processed })));
|
||||
if (exclusive) {
|
||||
nc.publish(SUBJECTS.INTERNAL, m.data);
|
||||
}
|
||||
if (m.reply) m.respond(sc.encode(JSON.stringify({ ok: true, processed, exclusive })));
|
||||
if (typeof m.ack === 'function') await m.ack();
|
||||
};
|
||||
if (exclusive) {
|
||||
try {
|
||||
const js = nc.jetstream();
|
||||
const jsm = await nc.jetstreamManager();
|
||||
await jsm.consumers.add('ZAPIER_EVENTS', {
|
||||
durable_name: SUBJECTS.DURABLE,
|
||||
ack_policy: 'explicit',
|
||||
filter_subject: SUBJECTS.IN,
|
||||
max_deliver: 10,
|
||||
}).catch(() => {});
|
||||
const consumer = await js.consumers.get('ZAPIER_EVENTS', SUBJECTS.DURABLE);
|
||||
const messages = await consumer.consume({ max_messages: 10 });
|
||||
for await (const m of messages) await deliver(m);
|
||||
return;
|
||||
} catch (err) {
|
||||
process.stderr.write(`js exclusive fallback core sub: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
for await (const m of nc.subscribe(SUBJECTS.IN, { queue: SUBJECTS.QUEUE })) {
|
||||
await deliver(m);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +55,14 @@ const server = http.createServer((req, res) => {
|
|||
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
|
||||
if (url.pathname === '/health') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, role: 'verae-jobs-events', processed, subject: SUBJECTS.IN, lastEvent: last?.event || null }));
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
role: 'verae-jobs-events',
|
||||
processed,
|
||||
subject: SUBJECTS.IN,
|
||||
exclusive: process.env.JOBS_EVENTS_EXCLUSIVE === '1',
|
||||
lastEvent: last?.event || null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const SUBJECTS = {
|
||||
IN: 'verae.zapier.jobs.events',
|
||||
INTERNAL: 'verae.internal.jobs.events',
|
||||
QUEUE: 'jobs-events',
|
||||
DURABLE: 'jobs-events-exclusive',
|
||||
};
|
||||
|
|
|
|||
3
packages/verae-staff-session/NATS.md
Normal file
3
packages/verae-staff-session/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS
|
||||
|
||||
No subjects. HTTP cookie only.
|
||||
9
packages/verae-staff-session/README.md
Normal file
9
packages/verae-staff-session/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# verae-staff-session
|
||||
|
||||
Shared cookie login for CS / sales / accounting / access-staff HTML.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-session
|
||||
|
||||
Port `:3027`. Set `STAFF_AUTH=1` on the department servers and `STAFF_SESSION_URL=http://127.0.0.1:3027`. Cookie host is the browser host (ports share `127.0.0.1`). JSON APIs stay open unless you also send `x-staff-key`.
|
||||
|
||||
Default key: `STAFF_KEY` or `ADMIN_KEY` or `admin-dev-key`.
|
||||
3
packages/verae-staff-session/SUMMARY.md
Normal file
3
packages/verae-staff-session/SUMMARY.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# verae-staff-session
|
||||
|
||||
HMAC staff cookie (`staff_session`) so CS, sales, accounting, and access-staff HTML share one login on the operator host.
|
||||
11
packages/verae-staff-session/package.json
Normal file
11
packages/verae-staff-session/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "verae-staff-session",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Shared staff cookie login for CS / sales / accounting / access-staff HTML",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
}
|
||||
}
|
||||
14
packages/verae-staff-session/src/gate.js
Normal file
14
packages/verae-staff-session/src/gate.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { allowed } from './token.js';
|
||||
|
||||
/** Redirect HTML to the staff login when STAFF_AUTH=1. JSON APIs stay open unless STAFF_AUTH_JSON=1. */
|
||||
export function staffHtmlGuard(req, res, url) {
|
||||
if (process.env.STAFF_AUTH !== '1') return false;
|
||||
const html = req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html');
|
||||
if (!html) return false;
|
||||
if (allowed(req)) return false;
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const next = `http://${req.headers.host || '127.0.0.1'}${url.pathname}`;
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` });
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
100
packages/verae-staff-session/src/server.js
Normal file
100
packages/verae-staff-session/src/server.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env node
|
||||
import http from 'node:http';
|
||||
import { cookieHeader, sessionToken, staffKey } from './token.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3027);
|
||||
|
||||
const LOGIN = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Staff sign-in</title>
|
||||
<style>
|
||||
:root { --accent:#4f46e5; --bg:#f4f5fb; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font:14px/1.45 -apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
|
||||
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); }
|
||||
.card { width:360px; background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
|
||||
h1 { margin:0 0 .25rem; font-size:1.3rem; }
|
||||
p { color:var(--muted); margin:0 0 1.2rem; }
|
||||
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
|
||||
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
|
||||
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
|
||||
.err { color:#dc2626; min-height:1.2em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section id="auth">
|
||||
<form class="card" method="post" action="/login">
|
||||
<h1>Staff sign-in</h1>
|
||||
<p>One cookie covers CS, sales, accounting, and the staff plane on this host.</p>
|
||||
<input type="hidden" name="next" id="next"/>
|
||||
<label for="password">Staff key</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required/>
|
||||
<p class="err" id="err"></p>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</section>
|
||||
<script>
|
||||
const q = new URLSearchParams(location.search);
|
||||
document.getElementById('next').value = q.get('next') || '';
|
||||
if (q.get('error')) document.getElementById('err').textContent = 'Wrong key.';
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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));
|
||||
};
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return json(200, { ok: true, role: 'verae-staff-session' });
|
||||
}
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
return res.end(LOGIN);
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/check') {
|
||||
const raw = req.headers.cookie || '';
|
||||
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
|
||||
return json(m && m[1] === sessionToken() ? 200 : 401, { ok: Boolean(m && m[1] === sessionToken()) });
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/login') {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let password = '';
|
||||
let next = '/';
|
||||
if ((req.headers['content-type'] || '').includes('json')) {
|
||||
const body = JSON.parse(text || '{}');
|
||||
password = body.password || '';
|
||||
next = body.next || '/';
|
||||
} else {
|
||||
const params = new URLSearchParams(text);
|
||||
password = params.get('password') || '';
|
||||
next = params.get('next') || '/';
|
||||
}
|
||||
if (password !== staffKey()) {
|
||||
res.writeHead(302, { location: '/login?error=1' });
|
||||
return res.end();
|
||||
}
|
||||
const loc = next.startsWith('http') || next.startsWith('/') ? next : '/';
|
||||
res.writeHead(302, { 'set-cookie': cookieHeader(), location: loc });
|
||||
return res.end();
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/logout') {
|
||||
res.writeHead(302, {
|
||||
'set-cookie': 'staff_session=; Path=/; Max-Age=0',
|
||||
location: '/login',
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
json(404, { error: 'not found' });
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
process.stdout.write(`verae-staff-session http://0.0.0.0:${PORT}/\n`);
|
||||
});
|
||||
28
packages/verae-staff-session/src/token.js
Normal file
28
packages/verae-staff-session/src/token.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
export function staffKey() {
|
||||
return process.env.STAFF_KEY || process.env.ADMIN_KEY || 'admin-dev-key';
|
||||
}
|
||||
|
||||
export function sessionToken() {
|
||||
return crypto.createHmac('sha256', staffKey()).update('verae-staff').digest('hex');
|
||||
}
|
||||
|
||||
export function cookieHeader() {
|
||||
return `staff_session=${sessionToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`;
|
||||
}
|
||||
|
||||
export function cookieOk(req) {
|
||||
const raw = req.headers?.cookie || '';
|
||||
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
|
||||
return Boolean(m && m[1] === sessionToken());
|
||||
}
|
||||
|
||||
export function headerOk(req) {
|
||||
const k = req.headers?.['x-staff-key'];
|
||||
return k === staffKey();
|
||||
}
|
||||
|
||||
export function allowed(req) {
|
||||
return cookieOk(req) || headerOk(req);
|
||||
}
|
||||
11
packages/verae-staff-session/test/token.test.js
Normal file
11
packages/verae-staff-session/test/token.test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sessionToken, cookieOk, headerOk } from '../src/token.js';
|
||||
|
||||
test('cookie matches HMAC of staff key', () => {
|
||||
const tok = sessionToken();
|
||||
assert.equal(tok.length, 64);
|
||||
assert.equal(cookieOk({ headers: { cookie: `staff_session=${tok}` } }), true);
|
||||
assert.equal(cookieOk({ headers: { cookie: 'staff_session=nope' } }), false);
|
||||
assert.equal(headerOk({ headers: { 'x-staff-key': process.env.STAFF_KEY || 'admin-dev-key' } }), true);
|
||||
});
|
||||
|
|
@ -68,16 +68,17 @@ export async function startWebhookWorker() {
|
|||
const { js, jsm } = await connectNats();
|
||||
await ensureStreams(jsm);
|
||||
|
||||
// Events consumer → deliver
|
||||
try {
|
||||
await jsm.consumers.add(STREAMS.ZAPIER_EVENTS, {
|
||||
durable_name: CONSUMERS.EVENT_WEBHOOK_ROUTER,
|
||||
ack_policy: 'explicit',
|
||||
filter_subject: SUBJECTS.JOBS_EVENTS,
|
||||
max_deliver: 10,
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug('events consumer may exist', { error: err.message });
|
||||
if (process.env.JOBS_EVENTS_EXCLUSIVE !== '1') {
|
||||
try {
|
||||
await jsm.consumers.add(STREAMS.ZAPIER_EVENTS, {
|
||||
durable_name: CONSUMERS.EVENT_WEBHOOK_ROUTER,
|
||||
ack_policy: 'explicit',
|
||||
filter_subject: SUBJECTS.JOBS_EVENTS,
|
||||
max_deliver: 10,
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug('events consumer may exist', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -117,9 +118,13 @@ export async function startWebhookWorker() {
|
|||
}
|
||||
};
|
||||
|
||||
runConsumer(STREAMS.ZAPIER_EVENTS, CONSUMERS.EVENT_WEBHOOK_ROUTER).catch((err) =>
|
||||
log.error('events consumer failed', { error: err.message }),
|
||||
);
|
||||
if (process.env.JOBS_EVENTS_EXCLUSIVE === '1') {
|
||||
log.info('JOBS_EVENTS_EXCLUSIVE=1 — skipping event-webhook-router; verae-jobs-events owns ZAPIER_EVENTS');
|
||||
} else {
|
||||
runConsumer(STREAMS.ZAPIER_EVENTS, CONSUMERS.EVENT_WEBHOOK_ROUTER).catch((err) =>
|
||||
log.error('events consumer failed', { error: err.message }),
|
||||
);
|
||||
}
|
||||
runConsumer(STREAMS.ZAPIER_WEBHOOKS, CONSUMERS.WEBHOOK_DELIVER).catch((err) =>
|
||||
log.error('webhooks consumer failed', { error: err.message }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@
|
|||
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
|
||||
- SSH spawn timeout 8s; failed hosts skipped.
|
||||
|
||||
## 2026-09-11 — names, staff session, exclusive jobs.events
|
||||
|
||||
- Account-balance stores display names (`customer.put` + lookup by name). Edge writes names on customer create/edit; CS/sales/accounting/staff join from edge if the ledger has no name.
|
||||
- New repo `verae-staff-session` (:3027). Department HTML redirects when `STAFF_AUTH=1`.
|
||||
- `JOBS_EVENTS_EXCLUSIVE=1` makes `verae-jobs-events` the durable consumer; middleware webhook router backs off.
|
||||
- Catalog index is cards. Disabled fleet machines are grey, not degraded yellow.
|
||||
|
||||
## 2026-09-11 — UI design-system pass
|
||||
|
||||
- Restyled CS/sales/accounting/access-staff to portal indigo; dollars + names.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- [ ] Live `api.veraetime.net` with `MOCK_VERAE=false` and admin bind credentials.
|
||||
- [ ] NATS nkeys/mTLS on a real three-node cluster (accounts file is the lab stand-in).
|
||||
- [ ] Exclusive JetStream consumer for `verae.zapier.jobs.events` on `verae-jobs-events` (middleware still also listens).
|
||||
- [ ] Auth on CS/sales/accounting HTML (staff plane is the intended door).
|
||||
- [x] Exclusive JetStream consumer for `verae.zapier.jobs.events` on `verae-jobs-events` (`JOBS_EVENTS_EXCLUSIVE=1`; middleware skips the router).
|
||||
- [x] Auth on CS/sales/accounting HTML via `verae-staff-session` (`STAFF_AUTH=1`).
|
||||
- [ ] Zapier Platform `push` of a private app.
|
||||
- [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge).
|
||||
|
|
|
|||
|
|
@ -7,21 +7,24 @@ export class AccountBooks {
|
|||
this.prepaid = {};
|
||||
/** @type {Record<string, string>} */
|
||||
this.veraeUserIds = {};
|
||||
/** @type {Record<string, string>} */
|
||||
this.names = {};
|
||||
this.credits = [];
|
||||
this.usage = [];
|
||||
this.payments = [];
|
||||
}
|
||||
|
||||
remember(customerId, veraeUserId) {
|
||||
remember(customerId, veraeUserId, name) {
|
||||
if (customerId && veraeUserId) this.veraeUserIds[customerId] = veraeUserId;
|
||||
if (customerId && name) this.names[customerId] = name;
|
||||
}
|
||||
|
||||
prepaidCents(customerId) {
|
||||
return this.prepaid[customerId] ?? 0;
|
||||
}
|
||||
|
||||
adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId }) {
|
||||
this.remember(customerId, veraeUserId);
|
||||
adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId, name }) {
|
||||
this.remember(customerId, veraeUserId, name);
|
||||
const delta = Math.trunc(Number(cents) || 0);
|
||||
const next = this.prepaidCents(customerId) + delta;
|
||||
this.prepaid[customerId] = next;
|
||||
|
|
@ -41,7 +44,7 @@ export class AccountBooks {
|
|||
}
|
||||
|
||||
recordUsage(entry) {
|
||||
this.remember(entry.customerId, entry.veraeUserId);
|
||||
this.remember(entry.customerId, entry.veraeUserId, entry.name);
|
||||
const cents = Number(entry.cents) || 0;
|
||||
const customerId = entry.customerId;
|
||||
const next = this.prepaidCents(customerId) - cents;
|
||||
|
|
@ -67,10 +70,20 @@ export class AccountBooks {
|
|||
});
|
||||
}
|
||||
|
||||
lookup(idOrName) {
|
||||
if (this.names[idOrName] || this.prepaid[idOrName] != null || this.veraeUserIds[idOrName]) {
|
||||
return this.statement(idOrName);
|
||||
}
|
||||
const want = String(idOrName || '').toLowerCase();
|
||||
const hit = Object.entries(this.names).find(([, n]) => String(n).toLowerCase() === want);
|
||||
return this.statement(hit ? hit[0] : idOrName);
|
||||
}
|
||||
|
||||
statement(customerId) {
|
||||
const match = (rows) => rows.filter((r) => r.customerId === customerId).slice(0, 100);
|
||||
return {
|
||||
customerId,
|
||||
name: this.names[customerId],
|
||||
veraeUserId: this.veraeUserIds[customerId],
|
||||
prepaidCents: this.prepaidCents(customerId),
|
||||
credits: match(this.credits),
|
||||
|
|
@ -83,6 +96,7 @@ export class AccountBooks {
|
|||
return {
|
||||
prepaid: this.prepaid,
|
||||
veraeUserIds: this.veraeUserIds,
|
||||
names: this.names,
|
||||
credits: this.credits,
|
||||
usage: this.usage,
|
||||
payments: this.payments,
|
||||
|
|
@ -93,6 +107,7 @@ export class AccountBooks {
|
|||
if (!raw || typeof raw !== 'object') return this;
|
||||
this.prepaid = raw.prepaid || {};
|
||||
this.veraeUserIds = raw.veraeUserIds || {};
|
||||
this.names = raw.names || {};
|
||||
this.credits = Array.isArray(raw.credits) ? raw.credits : [];
|
||||
this.usage = Array.isArray(raw.usage) ? raw.usage : [];
|
||||
this.payments = Array.isArray(raw.payments) ? raw.payments : [];
|
||||
|
|
@ -102,9 +117,13 @@ export class AccountBooks {
|
|||
|
||||
export function handle(subject, payload, books) {
|
||||
const p = payload || {};
|
||||
if (subject.endsWith('customer.put')) {
|
||||
books.remember(p.customerId, p.veraeUserId, p.name);
|
||||
return books.lookup(p.customerId);
|
||||
}
|
||||
if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) {
|
||||
books.remember(p.customerId, p.veraeUserId);
|
||||
return books.statement(p.customerId);
|
||||
books.remember(p.customerId, p.veraeUserId, p.name);
|
||||
return books.lookup(p.customerId);
|
||||
}
|
||||
if (subject.endsWith('balance.adjust') || subject.endsWith('credit.applied')) {
|
||||
return books.adjust(p);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ async function startNats() {
|
|||
reply(nc.subscribe(SUBJECTS.BALANCE_ADJUST, { queue: SUBJECTS.QUEUE }), (p) =>
|
||||
apply(SUBJECTS.BALANCE_ADJUST, p),
|
||||
);
|
||||
reply(nc.subscribe(SUBJECTS.CUSTOMER_PUT, { queue: SUBJECTS.QUEUE }), (p) =>
|
||||
apply(SUBJECTS.CUSTOMER_PUT, p),
|
||||
);
|
||||
(async () => {
|
||||
for await (const m of nc.subscribe(SUBJECTS.USAGE_RECORDED)) {
|
||||
apply(SUBJECTS.USAGE_RECORDED, JSON.parse(sc.decode(m.data) || '{}'));
|
||||
|
|
@ -80,7 +83,11 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
const st = url.pathname.match(/^\/statement\/([^/]+)$/);
|
||||
if (req.method === 'GET' && st) {
|
||||
return json(200, books.statement(st[1]));
|
||||
return json(200, books.lookup(decodeURIComponent(st[1])));
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/customer') {
|
||||
const body = await readBody(req);
|
||||
return json(200, apply(SUBJECTS.CUSTOMER_PUT, body));
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/adjust') {
|
||||
const body = await readBody(req);
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@ export const SUBJECTS = {
|
|||
USAGE_RECORDED: 'verae.billing.usage.recorded',
|
||||
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
|
||||
CREDIT_APPLIED: 'verae.billing.credit.applied',
|
||||
CUSTOMER_PUT: 'verae.billing.customer.put',
|
||||
QUEUE: 'account-balance',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ test('adjust credits prepaid and statement lists credits usage payments', () =>
|
|||
assert.equal(st.payments[0].kind, 'payment');
|
||||
});
|
||||
|
||||
test('customer.put stores display name and lookup by name', () => {
|
||||
const books = new AccountBooks();
|
||||
handle(SUBJECTS.CUSTOMER_PUT, { customerId: 'cust_1', name: 'Ada (free)' }, books);
|
||||
const st = handle(SUBJECTS.STATEMENT_GET, { customerId: 'Ada (free)' }, books);
|
||||
assert.equal(st.customerId, 'cust_1');
|
||||
assert.equal(st.name, 'Ada (free)');
|
||||
});
|
||||
|
||||
test('persist round-trip keeps prepaid', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'books-'));
|
||||
process.env.BOOKS_PATH = path.join(dir, 'books.json');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
|
|
@ -8,9 +10,10 @@ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|||
|
||||
test('account-balance health and statement', async () => {
|
||||
const port = 18010;
|
||||
const books = path.join(os.tmpdir(), `books-health-${Date.now()}.json`);
|
||||
const child = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
env: { ...process.env, PORT: String(port), BOOKS_PATH: books },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
|
@ -26,5 +29,6 @@ test('account-balance health and statement', async () => {
|
|||
assert.equal(st.prepaidCents, 200);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
fs.rmSync(books, { force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
25
packages/zappier-accounting-export/src/names.js
Normal file
25
packages/zappier-accounting-export/src/names.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** Join ledger ids to zappier-edge customer display names. */
|
||||
export async function withCustomerName(st, idOrName, edge, key) {
|
||||
const out = { ...(st || {}) };
|
||||
if (out.name && out.customerId) return out;
|
||||
try {
|
||||
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||
headers: { 'x-admin-key': key },
|
||||
});
|
||||
const { customers } = await r.json();
|
||||
const want = String(idOrName || out.customerId || '').toLowerCase();
|
||||
const c = (customers || []).find(
|
||||
(x) =>
|
||||
x.id === idOrName ||
|
||||
x.id === out.customerId ||
|
||||
String(x.name || '').toLowerCase() === want,
|
||||
);
|
||||
if (c) {
|
||||
out.name = c.name;
|
||||
out.customerId = c.id;
|
||||
}
|
||||
} catch {
|
||||
/* edge optional */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import http from 'node:http';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
import { withCustomerName } from './names.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3013);
|
||||
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
||||
|
|
@ -26,6 +27,15 @@ const server = http.createServer(async (req, res) => {
|
|||
const json = (code, obj) => send(code, 'application/json', JSON.stringify(obj));
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
if (!chk || !chk.ok) {
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
|
|
@ -33,12 +43,14 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
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 id = decodeURIComponent(review[1]);
|
||||
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id });
|
||||
if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY));
|
||||
const r = await fetch(`${BOOKS}/statement/${encodeURIComponent(id)}`);
|
||||
if (r.ok) return json(200, await withCustomerName({ ...(await r.json()), source: 'account-balance' }, id, EDGE, KEY));
|
||||
const e = await edge(`/admin/api/statement/${encodeURIComponent(id)}`);
|
||||
const body = await e.json().catch(() => ({}));
|
||||
return json(e.status, await withCustomerName(body, id, EDGE, KEY));
|
||||
}
|
||||
const period = url.searchParams.get('period');
|
||||
const q = period ? `?period=${encodeURIComponent(period)}` : '';
|
||||
|
|
|
|||
25
packages/zappier-customer-service/src/names.js
Normal file
25
packages/zappier-customer-service/src/names.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** Join ledger ids to zappier-edge customer display names. */
|
||||
export async function withCustomerName(st, idOrName, edge, key) {
|
||||
const out = { ...(st || {}) };
|
||||
if (out.name && out.customerId) return out;
|
||||
try {
|
||||
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||
headers: { 'x-admin-key': key },
|
||||
});
|
||||
const { customers } = await r.json();
|
||||
const want = String(idOrName || out.customerId || '').toLowerCase();
|
||||
const c = (customers || []).find(
|
||||
(x) =>
|
||||
x.id === idOrName ||
|
||||
x.id === out.customerId ||
|
||||
String(x.name || '').toLowerCase() === want,
|
||||
);
|
||||
if (c) {
|
||||
out.name = c.name;
|
||||
out.customerId = c.id;
|
||||
}
|
||||
} catch {
|
||||
/* edge optional */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import http from 'node:http';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
import { withCustomerName } from './names.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
|
|
@ -32,6 +33,16 @@ const server = http.createServer(async (req, res) => {
|
|||
};
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
if (!chk || !chk.ok) {
|
||||
const next = `http://${req.headers.host || '127.0.0.1'}/`;
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
return;
|
||||
|
|
@ -41,7 +52,9 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||
if (req.method === 'GET' && review) {
|
||||
const out = await statement(review[1]);
|
||||
const id = decodeURIComponent(review[1]);
|
||||
const out = await statement(id);
|
||||
out.body = await withCustomerName(out.body, id, EDGE, KEY);
|
||||
return json(out.status, out.body);
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/credits') {
|
||||
|
|
|
|||
25
packages/zappier-sales-pricing/src/names.js
Normal file
25
packages/zappier-sales-pricing/src/names.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** Join ledger ids to zappier-edge customer display names. */
|
||||
export async function withCustomerName(st, idOrName, edge, key) {
|
||||
const out = { ...(st || {}) };
|
||||
if (out.name && out.customerId) return out;
|
||||
try {
|
||||
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||
headers: { 'x-admin-key': key },
|
||||
});
|
||||
const { customers } = await r.json();
|
||||
const want = String(idOrName || out.customerId || '').toLowerCase();
|
||||
const c = (customers || []).find(
|
||||
(x) =>
|
||||
x.id === idOrName ||
|
||||
x.id === out.customerId ||
|
||||
String(x.name || '').toLowerCase() === want,
|
||||
);
|
||||
if (c) {
|
||||
out.name = c.name;
|
||||
out.customerId = c.id;
|
||||
}
|
||||
} catch {
|
||||
/* edge optional */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import path from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||
import { withCustomerName } from './names.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
|
|
@ -36,6 +37,15 @@ const server = http.createServer(async (req, res) => {
|
|||
};
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
if (!chk || !chk.ok) {
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
|
||||
return;
|
||||
|
|
@ -45,12 +55,13 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
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 id = decodeURIComponent(review[1]);
|
||||
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id });
|
||||
if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY));
|
||||
const r = await fetch(`${BOOKS}/statement/${encodeURIComponent(id)}`);
|
||||
if (r.ok) return json(200, await withCustomerName({ ...(await r.json()), source: 'account-balance' }, id, EDGE, KEY));
|
||||
const e = await edge(`/admin/api/statement/${encodeURIComponent(id)}`);
|
||||
return json(e.status, await withCustomerName(e.body, id, EDGE, KEY));
|
||||
}
|
||||
const quote = url.pathname.match(/^\/quotes\/([^/]+)$/);
|
||||
if (req.method === 'GET' && quote) {
|
||||
|
|
|
|||
|
|
@ -226,10 +226,10 @@ async function viewInvoice(id) {
|
|||
|
||||
function renderStatement() {
|
||||
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
|
||||
const emptyCard = (title) =>
|
||||
`<div class="empty">${EMPTY_SVG}<h3>${title}</h3><p>Nothing recorded yet.</p></div>`;
|
||||
const row = (list, cols) => {
|
||||
if (!list || !list.length) {
|
||||
return `<tr><td colspan="${cols.length}"><div class="empty" style="padding:1rem">${EMPTY_SVG}<h3>Nothing here yet</h3></div></td></tr>`;
|
||||
}
|
||||
if (!list || !list.length) return '';
|
||||
return list
|
||||
.map(
|
||||
(r) =>
|
||||
|
|
@ -251,15 +251,21 @@ function renderStatement() {
|
|||
<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>Amount</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>Amount</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>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
|
||||
<div class="card"><h3>Credits</h3>${
|
||||
st.credits?.length
|
||||
? `<table><thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead><tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table>`
|
||||
: emptyCard('No credits yet')
|
||||
}</div>
|
||||
<div class="card"><h3>Usage</h3>${
|
||||
st.usage?.length
|
||||
? `<table><thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead><tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table>`
|
||||
: emptyCard('No usage yet')
|
||||
}</div>
|
||||
<div class="card"><h3>Payments</h3>${
|
||||
st.payments?.length
|
||||
? `<table><thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead><tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table>`
|
||||
: emptyCard('No payments yet')
|
||||
}</div>`;
|
||||
}
|
||||
|
||||
/* ---------------- billing ---------------- */
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { CreditLedger } from './credits';
|
|||
import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-export';
|
||||
import { composeStatement } from './statement';
|
||||
import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
|
||||
import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger';
|
||||
import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger';
|
||||
|
||||
// Issued login tokens (in-memory; a restart simply requires logging in again).
|
||||
const sessions = new Map<string, number>();
|
||||
|
|
@ -197,6 +197,7 @@ export function adminRouter(
|
|||
apiKey: `key-${randomBytes(12).toString('hex')}`,
|
||||
};
|
||||
customers.save(customer);
|
||||
void ledgerPutCustomer({ customerId: customer.id, name: customer.name });
|
||||
res.status(201).json(customer);
|
||||
});
|
||||
|
||||
|
|
@ -216,7 +217,7 @@ export function adminRouter(
|
|||
res.status(400).json({ error: 'billingType must be stripe or purchase_order' });
|
||||
return;
|
||||
}
|
||||
customers.save({
|
||||
const next = {
|
||||
...existing,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(tierId !== undefined ? { tierId } : {}),
|
||||
|
|
@ -224,7 +225,9 @@ export function adminRouter(
|
|||
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
|
||||
...(billingType !== undefined ? { billingType } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
});
|
||||
};
|
||||
customers.save(next);
|
||||
void ledgerPutCustomer({ customerId: next.id, name: next.name, veraeUserId: next.veraeUserId });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
|
@ -286,6 +289,7 @@ export function adminRouter(
|
|||
res.json({
|
||||
...composeStatement({
|
||||
customerId: customer.id,
|
||||
name: customer.name,
|
||||
veraeUserId: customer.veraeUserId,
|
||||
prepaidCents: customer.balanceCents ?? 0,
|
||||
credits: credits.list(customer.id),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const BILLING_SUBJECTS = {
|
|||
USAGE_RECORDED: 'verae.billing.usage.recorded',
|
||||
PAYMENT_RECORDED: 'verae.billing.payment.recorded',
|
||||
CREDIT_APPLIED: 'verae.billing.credit.applied',
|
||||
CUSTOMER_PUT: 'verae.billing.customer.put',
|
||||
};
|
||||
|
||||
export const AUTHZ_CHECK = 'verae.access.authz.check';
|
||||
|
|
@ -15,6 +16,7 @@ export type AccessPlane = 'zapier' | 'web' | 'api' | 'leaf' | 'staff';
|
|||
|
||||
export type BillingStatement = {
|
||||
customerId: string;
|
||||
name?: string;
|
||||
veraeUserId?: string;
|
||||
prepaidCents: number;
|
||||
credits: unknown[];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Prepaid mutations. Account-balance is the writer when NATS or HTTP books exist.
|
||||
* Tests (no NATS_URL, no ACCOUNT_BALANCE_URL) keep a local cache only.
|
||||
*/
|
||||
import { natsAdjust, natsStatement } from './billing-nats';
|
||||
import { natsAdjust, natsPublish, natsStatement } from './billing-nats';
|
||||
import type { AccessPlane } from './billing-nats';
|
||||
|
||||
export type PrepaidRow = {
|
||||
|
|
@ -39,6 +39,21 @@ export async function ledgerAdjust(row: PrepaidRow, plane: AccessPlane): Promise
|
|||
return (await r.json()) as PrepaidRow;
|
||||
}
|
||||
|
||||
export async function ledgerPutCustomer(row: {
|
||||
customerId: string;
|
||||
name?: string;
|
||||
veraeUserId?: string;
|
||||
}): Promise<void> {
|
||||
natsPublish('verae.billing.customer.put', row, 'staff');
|
||||
const base = booksUrl();
|
||||
if (!base) return;
|
||||
await fetch(`${base}/customer`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(row),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export async function ledgerStatement(customerId: string, plane: AccessPlane, veraeUserId?: string) {
|
||||
const nats = await natsStatement(customerId, plane, veraeUserId);
|
||||
if (nats) return nats;
|
||||
|
|
|
|||
|
|
@ -212,12 +212,17 @@ export function portalRouter(deps: PortalDeps): Router {
|
|||
router.get('/statement', async (req, res) => {
|
||||
const fromBooks = await ledgerStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
|
||||
if (fromBooks) {
|
||||
res.json({ ...fromBooks, source: fromBooks.source || 'account-balance' });
|
||||
res.json({
|
||||
...fromBooks,
|
||||
name: req.customer!.name,
|
||||
source: fromBooks.source || 'account-balance',
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
...composeStatement({
|
||||
customerId: req.customer!.id,
|
||||
name: req.customer!.name,
|
||||
veraeUserId: req.customer!.veraeUserId,
|
||||
prepaidCents: req.customer!.balanceCents ?? 0,
|
||||
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { UsageEntry } from './usage';
|
|||
|
||||
export function composeStatement(args: {
|
||||
customerId: string;
|
||||
name?: string;
|
||||
veraeUserId?: string;
|
||||
prepaidCents: number;
|
||||
credits: CreditAdjustment[];
|
||||
|
|
@ -22,6 +23,7 @@ export function composeStatement(args: {
|
|||
}));
|
||||
return {
|
||||
customerId: args.customerId,
|
||||
name: args.name,
|
||||
veraeUserId: args.veraeUserId,
|
||||
prepaidCents: args.prepaidCents,
|
||||
credits: args.credits.filter((c) => c.customerId === args.customerId),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue