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