From 0e41be62ca9c890f9aad708e4047380e7232577c Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Tue, 6 Oct 2026 13:22:22 -0400 Subject: [PATCH 1/2] chore: commit live drift from ns1:/opt/verae/zappier-edge (send-invoices job, overdue email HTML, portal #billing deep link) These files were running on ns1 but never committed. Copied verbatim so the next deploy from git does not regress them. --- package.json | 3 +- portal/app.js | 41 +++- src/jobs/overdue-balances.ts | 148 +++++++++++++- src/jobs/send-invoices.ts | 383 +++++++++++++++++++++++++++++++++++ 4 files changed, 563 insertions(+), 12 deletions(-) create mode 100644 src/jobs/send-invoices.ts diff --git a/package.json b/package.json index 8e041cc..8fab016 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "start": "node dist/index.js", "dev": "ts-node src/index.ts", "test": "jest", - "overdue": "node dist/jobs/overdue-balances.js" + "overdue": "node dist/jobs/overdue-balances.js", + "send-invoices": "node dist/jobs/send-invoices.js" }, "dependencies": { "@libsql/client": "^0.18.0", diff --git a/portal/app.js b/portal/app.js index be5b116..ec34bed 100644 --- a/portal/app.js +++ b/portal/app.js @@ -1038,18 +1038,49 @@ function renderDocs() { /* ---------------- tabs + boot ---------------- */ +function activateTab(tabId) { + const btn = document.querySelector(`nav button[data-tab="${tabId}"]`); + if (!btn) return false; + document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('main section').forEach((s) => (s.hidden = true)); + const section = document.getElementById(tabId); + if (section) section.hidden = false; + return true; +} + +function tabFromHash() { + const raw = (location.hash || '').replace(/^#/, '').toLowerCase(); + if (raw === 'billing' || raw === 'reload') return 'billing'; + if (raw === 'invoices' || raw === 'statement' || raw === 'security' || raw === 'docs' || raw === 'dashboard') return raw; + return null; +} + +function applyHashTab() { + const tab = tabFromHash(); + if (tab) activateTab(tab); +} + document.querySelectorAll('nav button').forEach((btn) => btn.addEventListener('click', () => { - document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - document.querySelectorAll('main section').forEach((s) => (s.hidden = true)); - document.getElementById(btn.dataset.tab).hidden = false; + activateTab(btn.dataset.tab); + if (btn.dataset.tab && location.hash) { + // keep deep-link in sync when user clicks Billing after arriving via #billing + const h = btn.dataset.tab === 'billing' ? '#billing' : `#${btn.dataset.tab}`; + if (location.hash.toLowerCase() !== h) history.replaceState(null, '', h); + } }), ); +window.addEventListener('hashchange', () => { + if (token()) applyHashTab(); +}); + if (token()) { showShell(); - load().catch((err) => say(err.message, true)); + load() + .then(() => applyHashTab()) + .catch((err) => say(err.message, true)); } else { showAuth(); } diff --git a/src/jobs/overdue-balances.ts b/src/jobs/overdue-balances.ts index 1aa5352..77d1c8d 100644 --- a/src/jobs/overdue-balances.ts +++ b/src/jobs/overdue-balances.ts @@ -18,6 +18,7 @@ * node dist/jobs/overdue-balances.js --threshold-cents -500 # only owing > $5 * node dist/jobs/overdue-balances.js --send # email each customer * node dist/jobs/overdue-balances.js --send --to-override ops@example.com + * node dist/jobs/overdue-balances.js --send --customer-id cust_xxx --to-override ops@example.com * * Env: BILLING_DB, SMTP_* (see src/mailer.ts), PORTAL_URL * (default https://zap.verae.ai/portal/), OVERDUE_THRESHOLD_CENTS (default 0; @@ -142,11 +143,30 @@ export function usd(cents: number): string { return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`; } +const DEFAULT_PORTAL_URL = 'https://zap.verae.ai/portal/'; +const DEFAULT_LOGO_URL = 'https://mcp.verae.ai/assets/VeraeFullLogo.png'; + +function escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + export function paymentRequestEmail( pos: AccountPosition, - portalUrl = 'https://zap.verae.ai/portal/', -): { subject: string; text: string } { + portalUrl = DEFAULT_PORTAL_URL, + logoUrl = DEFAULT_LOGO_URL, +): { subject: string; text: string; html: string } { const due = Math.max(0, -pos.effectiveBalanceCents); + const portal = portalUrl.endsWith('/') ? portalUrl : `${portalUrl}/`; + const loginUrl = portal; + const billingUrl = `${portal}#billing`; + const invNote = pos.outstandingInvoiceIds.length + ? ` (${pos.outstandingInvoiceIds.join(', ')})` + : ''; + const subject = `Verae payment request — ${usd(due)} due on ${pos.customerId}`; const lines = [ `Hello ${pos.name},`, '', @@ -154,17 +174,126 @@ export function paymentRequestEmail( 'Your timestamps keep working while the balance is negative; please top up at your convenience.', '', ` Prepaid balance: ${usd(pos.prepaidCents)}`, - ` Unpaid issued invoices: ${usd(pos.outstandingInvoiceCents)}${pos.outstandingInvoiceIds.length ? ` (${pos.outstandingInvoiceIds.join(', ')})` : ''}`, + ` Unpaid issued invoices: ${usd(pos.outstandingInvoiceCents)}${invNote}`, ` Usage not yet invoiced: ${usd(pos.unbilledUsageCents)}`, ` Effective balance: ${usd(pos.effectiveBalanceCents)}`, '', - `Reload your balance in the customer portal: ${portalUrl} (Billing → Reload balance).`, + `Log in: ${loginUrl}`, + `Reload balance (Billing tab): ${billingUrl}`, '', 'Questions? Just reply to this email.', '', '— Verae', ]; - return { subject: `Verae payment request — ${usd(due)} due on ${pos.customerId}`, text: lines.join('\n') }; + const text = lines.join('\n'); + + const name = escHtml(pos.name); + const custId = escHtml(pos.customerId); + const dueStr = escHtml(usd(due)); + const prepaidStr = escHtml(usd(pos.prepaidCents)); + const unpaidStr = escHtml(usd(pos.outstandingInvoiceCents) + invNote); + const unbilledStr = escHtml(usd(pos.unbilledUsageCents)); + const effectiveStr = escHtml(usd(pos.effectiveBalanceCents)); + const logo = escHtml(logoUrl); + const login = escHtml(loginUrl); + const billing = escHtml(billingUrl); + const issued = new Date().toISOString().slice(0, 10); + + const html = ` + + + + +${escHtml(subject)} + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ Verae +
+

Payment request / invoice

+

Balance due: ${dueStr}

+

Account ${custId} · Issued ${escHtml(issued)}

+
+

Hello ${name},

+

+ Your Verae timestamping account has a negative prepaid balance. Timestamps keep working while the balance is negative — please reload at your convenience. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
DescriptionAmount
Prepaid balance${prepaidStr}
Unpaid issued invoices${escHtml(invNote)}${unpaidStr}
Usage not yet invoiced${unbilledStr}
Effective balance${effectiveStr}
Amount due${dueStr}
+
+ + + + + +
+ Log in to portal + + Reload balance +
+

+ After sign-in, open the Billing tab to reload.
+ Portal: ${login} +

+
+

Questions? Just reply to this email.

+

— Verae

+

+ +

+
+
+ +`; + + return { subject, text, html }; } export async function sendPaymentRequests( @@ -239,7 +368,14 @@ async function main(argv = process.argv.slice(2)): Promise { } })(), }; - const rows = await listOverdueCustomers(deps, threshold); + let rows = await listOverdueCustomers(deps, threshold); + const onlyId = argValue(argv, '--customer-id'); + if (onlyId) { + rows = rows.filter((r) => r.customerId === onlyId); + if (!rows.length) { + console.error(`overdue-balances: no negative-balance customer matching --customer-id ${onlyId}`); + } + } if (argv.includes('--json')) { console.log(JSON.stringify({ thresholdCents: threshold, count: rows.length, customers: rows }, null, 2)); diff --git a/src/jobs/send-invoices.ts b/src/jobs/send-invoices.ts new file mode 100644 index 0000000..f6bab03 --- /dev/null +++ b/src/jobs/send-invoices.ts @@ -0,0 +1,383 @@ +/** + * One-shot / CLI invoice emailer. + * + * Sends a proper HTML invoice email (Verae logo, portal login + billing links, + * invoice id, line items, totals) for ISSUED invoices with billableCents > 0. + * Draft invoices and $0 billable invoices are never emailed. + * + * Usage (from /opt/verae/zappier-edge, after `npm run build`): + * node dist/jobs/send-invoices.js --invoice-id INV-2026-10-0003 + * node dist/jobs/send-invoices.js --ids INV-2026-10-0003,INV-2026-10-0007 --send + * node dist/jobs/send-invoices.js --period 2026-10 --status issued --send + * node dist/jobs/send-invoices.js --send --to-override ops@example.com --invoice-id INV-... + * + * Env: BILLING_DB, SMTP_* (see src/mailer.ts), PORTAL_URL + * (default https://zap.verae.ai/portal/), INVOICE_LOGO_URL + * (default https://mcp.verae.ai/assets/VeraeFullLogo.png). + * Nothing is emailed unless --send is passed. + */ +import type { Customer, CustomerRepo } from '../auth'; +import type { Invoice, InvoiceRepo } from '../invoicing'; +import type { Mailer } from '../mailer'; + +export interface InvoiceEmailDeps { + customers: Pick; + invoices: Pick; +} + +const DEFAULT_PORTAL_URL = 'https://zap.verae.ai/portal/'; +const DEFAULT_LOGO_URL = 'https://mcp.verae.ai/assets/VeraeFullLogo.png'; + +export function usd(cents: number): string { + const sign = cents < 0 ? '-' : ''; + return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`; +} + +function escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** True only for issued invoices that have a positive amount due. */ +export function isEmailableInvoice(inv: Pick): boolean { + return inv.status === 'issued' && Number.isFinite(inv.billableCents) && inv.billableCents > 0; +} + +export function invoiceEmail( + invoice: Invoice, + customer: Pick, + portalUrl = DEFAULT_PORTAL_URL, + logoUrl = DEFAULT_LOGO_URL, +): { subject: string; text: string; html: string } { + const portal = portalUrl.endsWith('/') ? portalUrl : `${portalUrl}/`; + const loginUrl = portal; + const billingUrl = `${portal}#billing`; + const due = usd(invoice.billableCents); + const issued = + invoice.issuedAtMs != null + ? new Date(invoice.issuedAtMs).toISOString().slice(0, 10) + : new Date().toISOString().slice(0, 10); + const dueDate = + invoice.dueAtMs != null ? new Date(invoice.dueAtMs).toISOString().slice(0, 10) : '—'; + + const subject = `Verae invoice ${invoice.id} — ${due} due`; + const lineText = invoice.lines + .map((l) => ` ${l.endpointId.padEnd(24)} ${String(l.calls).padStart(6)} calls ${usd(l.cents)}`) + .join('\n'); + const text = [ + `Hello ${customer.name},`, + '', + `Please find your Verae timestamping invoice ${invoice.id} for period ${invoice.period}.`, + '', + ` Invoice: ${invoice.id}`, + ` Account: ${customer.id}`, + ` Period: ${invoice.period}`, + ` Status: ${invoice.status}`, + ` Issued: ${issued}`, + ` Due date: ${dueDate}`, + '', + 'Line items:', + lineText || ' (none)', + '', + ` Usage total: ${usd(invoice.totalCents)}`, + ` Monthly credit: −${usd(invoice.creditCents)}`, + ` Amount due: ${due}`, + '', + `Log in: ${loginUrl}`, + `Pay / Billing tab: ${billingUrl}`, + '', + 'Questions? Just reply to this email.', + '', + '— Verae', + ].join('\n'); + + const name = escHtml(customer.name); + const custId = escHtml(customer.id); + const invId = escHtml(invoice.id); + const period = escHtml(invoice.period); + const status = escHtml(invoice.status.toUpperCase()); + const dueStr = escHtml(due); + const totalStr = escHtml(usd(invoice.totalCents)); + const creditStr = escHtml(usd(invoice.creditCents)); + const logo = escHtml(logoUrl); + const login = escHtml(loginUrl); + const billing = escHtml(billingUrl); + const issuedEsc = escHtml(issued); + const dueDateEsc = escHtml(dueDate); + + const rows = invoice.lines + .map( + (l) => ` + + ${escHtml(l.endpointId)} + ${l.calls} + ${escHtml(usd(l.cents))} + `, + ) + .join(''); + + const html = ` + + + + +${escHtml(subject)} + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ Verae +
+

Invoice

+

${invId}

+

+ Account ${custId} + · Period ${period} + · Issued ${issuedEsc} + · Due ${dueDateEsc} + · ${status} +

+
+

Hello ${name},

+

+ Here is your Verae timestamping invoice for period ${period}. Amount due: ${dueStr}. +

+
+ + + + + + + ${rows || ``} + + + + + + + + + + + + +
EndpointCallsAmount
No line items
Usage total${totalStr}
Monthly credit−${creditStr}
Amount due${dueStr}
+
+ + + + + +
+ Log in to portal + + Pay / Billing +
+

+ After sign-in, open the Billing tab to pay or reload.
+ Portal: ${login} +

+
+

Questions? Just reply to this email.

+

— Verae

+

+ +

+
+
+ +`; + + return { subject, text, html }; +} + +export async function sendInvoices( + invoiceIds: string[], + deps: InvoiceEmailDeps, + mailer: Mailer, + opts: { + toOverride?: string; + portalUrl?: string; + logoUrl?: string; + log?: (m: string) => void; + } = {}, +): Promise<{ + sent: { invoiceId: string; to: string }[]; + skipped: { invoiceId: string; reason: string }[]; +}> { + const log = opts.log ?? console.log; + const sent: { invoiceId: string; to: string }[] = []; + const skipped: { invoiceId: string; reason: string }[] = []; + if (!mailer.available()) throw new Error('SMTP not configured (SMTP_USER/SMTP_PASS)'); + + for (const id of invoiceIds) { + const invoice = deps.invoices.get(id); + if (!invoice) { + skipped.push({ invoiceId: id, reason: 'invoice not found' }); + log(`skipped ${id}: invoice not found`); + continue; + } + if (invoice.status === 'draft') { + skipped.push({ invoiceId: id, reason: 'draft invoice (not emailed)' }); + log(`skipped ${id}: draft invoice (not emailed)`); + continue; + } + if (!isEmailableInvoice(invoice)) { + const reason = + invoice.status !== 'issued' + ? `status ${invoice.status} (only issued)` + : `billable ${usd(invoice.billableCents)} (must be > $0)`; + skipped.push({ invoiceId: id, reason }); + log(`skipped ${id}: ${reason}`); + continue; + } + const customer = await deps.customers.findById(invoice.customerId); + if (!customer) { + skipped.push({ invoiceId: id, reason: `customer ${invoice.customerId} not found` }); + log(`skipped ${id}: customer ${invoice.customerId} not found`); + continue; + } + const to = opts.toOverride || customer.email; + if (!to) { + skipped.push({ invoiceId: id, reason: 'no email on file' }); + log(`skipped ${id}: no email on file (customer ${customer.id} / ${customer.name})`); + continue; + } + const msg = invoiceEmail(invoice, customer, opts.portalUrl, opts.logoUrl); + try { + await mailer.send({ to, ...msg }); + sent.push({ invoiceId: id, to }); + log( + `sent invoice ${id} to ${to} for ${customer.id} (${customer.name}) due ${usd(invoice.billableCents)}`, + ); + } catch (err) { + skipped.push({ invoiceId: id, reason: (err as Error).message }); + log(`failed ${id} → ${to}: ${(err as Error).message}`); + } + } + return { sent, skipped }; +} + +function argValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +} + +function collectInvoiceIds(argv: string[]): string[] { + const ids: string[] = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--invoice-id' && argv[i + 1]) { + ids.push(argv[++i]); + } + } + const csv = argValue(argv, '--ids'); + if (csv) { + for (const part of csv.split(',')) { + const id = part.trim(); + if (id) ids.push(id); + } + } + return [...new Set(ids)]; +} + +async function main(argv = process.argv.slice(2)): Promise { + const path = await import('path'); + const { config: loadEnv } = await import('dotenv'); + const { PROJECT_ROOT } = await import('../paths'); + loadEnv({ path: path.join(PROJECT_ROOT, '.env') }); + const { openSqlite } = await import('../db/open-sqlite'); + const { billingDbPath } = await import('../db/db-paths'); + const { SqliteCustomerRepo } = await import('../db/customer-repo'); + const { SqliteInvoiceRepo } = await import('../db/invoice-repo'); + const { smtpMailerFromEnv } = await import('../mailer'); + + const db = openSqlite(billingDbPath()); + const customers = new SqliteCustomerRepo(db); + const invoices = new SqliteInvoiceRepo(db); + + let ids = collectInvoiceIds(argv); + const period = argValue(argv, '--period'); + const status = argValue(argv, '--status') as Invoice['status'] | undefined; + if (!ids.length && (period || status)) { + ids = invoices + .list({ + ...(period ? { period } : {}), + ...(status ? { status } : {}), + }) + .map((i) => i.id); + } + if (!ids.length) { + console.error( + 'send-invoices: pass --invoice-id INV-... (repeatable), --ids a,b,c, or --period / --status', + ); + db.close(); + process.exit(2); + } + + console.log(`send-invoices: ${ids.length} invoice id(s) selected`); + for (const id of ids) { + const inv = invoices.get(id); + if (!inv) { + console.log(` ${id} (not found)`); + continue; + } + const cust = await customers.findById(inv.customerId); + console.log( + [ + id.padEnd(20), + inv.status.padEnd(8), + `due ${usd(inv.billableCents)}`.padEnd(12), + (cust?.email || '(no email)').padEnd(28), + cust ? `${cust.id} ${cust.name}` : inv.customerId, + ].join(' '), + ); + } + + if (argv.includes('--send')) { + const result = await sendInvoices(ids, { customers, invoices }, smtpMailerFromEnv(), { + toOverride: argValue(argv, '--to-override'), + portalUrl: process.env.PORTAL_URL || DEFAULT_PORTAL_URL, + logoUrl: process.env.INVOICE_LOGO_URL || DEFAULT_LOGO_URL, + }); + console.log( + `send-invoices: sent ${result.sent.length}, skipped ${result.skipped.length}`, + ); + for (const s of result.sent) console.log(` SENT ${s.invoiceId} → ${s.to}`); + for (const s of result.skipped) console.log(` skipped ${s.invoiceId}: ${s.reason}`); + } else { + console.log('send-invoices: dry run (pass --send to email invoices)'); + } + db.close(); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} -- 2.54.0 From 74e9183c5c83e343960111ce699dcfb0bda46072 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Tue, 6 Oct 2026 13:26:03 -0400 Subject: [PATCH 2/2] feat(billing): Usage & Billing API for the peergos-flutter app /portal/api/billing/{summary,usage,estimate,invoices,invoices/:id/pay[/confirm], payment-methods[/setup-intent|/checkout-session|/:id|/:id/default],po} behind the portal session; staff routes for storage samples and purchase orders. Additive tables storage_samples and purchase_orders (CREATE TABLE IF NOT EXISTS). Card routes 503 without a real Stripe key; nothing charges without customer confirmation. 9 new tests; full suite 283/283. --- docs/USAGE-BILLING-API.md | 49 ++ src/app.ts | 30 ++ src/billing-api.ts | 936 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 15 + src/portal.ts | 17 + tests/billing-api.test.ts | 257 +++++++++++ 6 files changed, 1304 insertions(+) create mode 100644 docs/USAGE-BILLING-API.md create mode 100644 src/billing-api.ts create mode 100644 tests/billing-api.test.ts diff --git a/docs/USAGE-BILLING-API.md b/docs/USAGE-BILLING-API.md new file mode 100644 index 0000000..7e82853 --- /dev/null +++ b/docs/USAGE-BILLING-API.md @@ -0,0 +1,49 @@ +# Usage & Billing API (`/portal/api/billing`) + +This API backs the **Usage & Billing** screen in the peergos-flutter app. The customer portal can use it too. + +**Auth.** Send the portal session as `Authorization: Bearer `, using the token from `POST /portal/api/login` or `/signup`. The signed-in Verae customer is the billing parent. A Peergos user is mapped to that customer through its `peergos:` child account (`parentId` = customer id). Passing `peergosUsername` scopes a request to one linked user; an unlinked user returns `404 peergos_user_not_linked`. + +**Existing portal routes, reused as they are:** +* `GET /portal/api/me`: profile, `billingType`, `balanceCents`. +* `GET /portal/api/invoices/:id?format=html`: printable invoice, with print-to-PDF in the app. +* `POST /portal/api/reload`: prepaid top-up. + +## Customer routes + +| Method | Path | Returns / does | +|--------|------|----------------| +| GET | `/billing/summary` | Customer (billing type, prepaid balance), plan/tier (credit, included GB and stamps), `storageOverageCentsPerGb` (2.875 = AWS 2.3¢ × 1.25), period, `standing` (`good`/`past_due`/`restricted`), `stripeConfigured`, `publishableKey` | +| GET | `/billing/usage[?peergosUsername=]` | Storage: pooled bytes across the customer's Peergos users, % of included GB, per-user latest sample, and a daily pooled-peak series for the chart. Stamps: used this month vs the tier allowance, plus cents by endpoint | +| GET | `/billing/estimate` | Month-to-date estimate, prorated by day: base (0 until storage plans exist), storage overage (average of daily peaks over included GB at 2875 millicents/GB-month), stamps, minus the plan credit (D2). Also prepaid applied first (D7), amount due, and month-end projection | +| GET | `/billing/invoices` | Non-draft invoices with `status` (`paid`/`unpaid`/`overdue`), dates, amounts, `htmlPath`, and `payable` | +| POST | `/billing/invoices/:id/pay` | Stripe customers: creates a PaymentIntent for the amount due (charged only when the customer confirms in PaymentSheet/Checkout). Returns `clientSecret`, `ephemeralKeySecret`, `stripeCustomerId` | +| POST | `/billing/invoices/:id/pay/confirm` `{paymentIntentId}` | Re-reads the PaymentIntent from Stripe. Marks the invoice paid only if it `succeeded` for this customer and invoice and covers the amount | +| GET | `/billing/payment-methods` | Saved cards plus `defaultPaymentMethodId` | +| POST | `/billing/payment-methods/setup-intent` | Creates the Stripe Customer if needed, then a SetupIntent and an ephemeral key for flutter_stripe PaymentSheet | +| POST | `/billing/payment-methods/checkout-session` | Stripe Checkout in `setup` mode, for desktop (webview/browser). Returns `{url}` | +| DELETE | `/billing/payment-methods/:id` | Detaches the card (only if it belongs to this customer) | +| POST | `/billing/payment-methods/:id/default` | Sets `invoice_settings.default_payment_method` | +| GET | `/billing/po` | Purchase-order customers: current PO (number, ceiling, invoiced, remaining, expiry, net terms) and PO history | + +Card routes return **503 `stripe_unavailable`** when no real `STRIPE_SECRET_KEY` is configured, and **409 `purchase_order_customer`** for PO accounts. `STRIPE_PUBLISHABLE_KEY` (optional env) is passed through for the app. + +## Staff routes (`/admin/api`, admin auth) + +| Method | Path | Does | +|--------|------|------| +| POST | `/peergos/storage/samples` `{samples:[{peergosUsername, bytes, sampledAt?}]}` | Stores usage samples from the ns2 sampler (design §3.2). Unlinked usernames are reported back, not stored | +| GET | `/customers/:id/purchase-orders` | PO list | +| POST | `/customers/:id/purchase-orders` `{poNumber, amountCents?, netTermsDays?, startsAt?, expiresAt?, note?}` | Attaches a PO; any active PO becomes `replaced` | +| POST | `/customers/:id/purchase-orders/:poId/renew` `{expiresAt?, amountCents?}` | Extends or raises a PO | + +## Storage (additive) +`billing.db` gains two tables, created with `CREATE TABLE IF NOT EXISTS` at startup: +* `storage_samples(id, customer_id, peergos_username, bytes, sampled_at_ms)` +* `purchase_orders(id, customer_id, po_number, amount_cents, starts_at_ms, expires_at_ms, net_terms_days, status, created_at_ms, created_by, note)` + +Nothing existing is altered or dropped. + +## Not yet included +* There is no Stripe webhook. Pay-now marks the invoice paid through the `/pay/confirm` server-side check instead. +* `standing` is derived from overdue invoices: `restricted` after 7 days overdue, the N10 proposal. Nothing enforces it on Peergos yet; see design §3.11. diff --git a/src/app.ts b/src/app.ts index 045da01..dedba90 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,13 @@ import path from 'path'; import { createHash, randomUUID } from 'crypto'; +import { + BillingStripe, + billingAdminRouter, + InMemoryPurchaseOrderRepo, + InMemoryStorageSampleRepo, + PurchaseOrderRepo, + StorageSampleRepo, +} from './billing-api'; import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; import swaggerUi from 'swagger-ui-express'; @@ -241,6 +249,12 @@ export interface AppDeps { credits?: CreditLedgerLike; /** Long-lived xVerae- customer API tokens (LLM-key style). */ apiTokens?: ApiTokenRepo; + /** Usage & Billing API stores (additive tables on billing.db in production). */ + storageSamples?: StorageSampleRepo; + purchaseOrders?: PurchaseOrderRepo; + /** Stripe client for card management / pay-now; undefined → 503 on those routes. */ + billingStripe?: BillingStripe; + stripePublishableKey?: string; } const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml'); @@ -343,6 +357,14 @@ export function buildApp(deps: AppDeps = {}): { }), ); + const storageSamples = deps.storageSamples ?? new InMemoryStorageSampleRepo(); + const purchaseOrders = deps.purchaseOrders ?? new InMemoryPurchaseOrderRepo(); + const billingBase = { + samples: storageSamples, + purchaseOrders, + stripe: deps.billingStripe, + publishableKey: deps.stripePublishableKey, + }; app.use( '/admin/api', adminLoginRouter(adminUsers, deps.mailer), @@ -355,6 +377,13 @@ export function buildApp(deps: AppDeps = {}): { credits, deps.channelRates, ), + billingAdminRouter({ + ...billingBase, + customers, + usage, + invoices, + tiers: () => pricingStore.getTiers(), + }), ); app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin'))); app.use('/client', express.static(path.join(PROJECT_ROOT, 'client'))); @@ -385,6 +414,7 @@ export function buildApp(deps: AppDeps = {}): { credits, apiTokens, mailer: deps.mailer, + billing: billingBase, }), ); app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal'))); diff --git a/src/billing-api.ts b/src/billing-api.ts new file mode 100644 index 0000000..f2fb267 --- /dev/null +++ b/src/billing-api.ts @@ -0,0 +1,936 @@ +import { randomBytes } from 'crypto'; +import type Database from 'better-sqlite3'; +import { Router } from 'express'; +import Stripe from 'stripe'; +import { Customer, CustomerRepo } from './auth'; +import { Invoice, InvoiceRepo } from './invoicing'; +import { peergosCustomerId } from './peergos-billing'; +import { + DEFAULT_STORAGE_PIN, + hostedOverageMillicentsPerGb, + StoragePinConfig, + TierConfig, +} from './pricing'; +import { UsageRepo } from './usage'; + +/** + * Usage & Billing API for the peergos-flutter app and the customer portal. + * + * Mounted at /portal/api/billing behind the portal session (Bearer) auth, so the + * signed-in Verae customer is req.customer. A Peergos user is mapped to its Verae + * customer through the `peergos:` child account (parentId = customer). + * + * Nothing here charges a card on its own: SetupIntents save a card, and a + * pay-now PaymentIntent is only charged when the customer confirms it client-side. + * Paid status is set only after the server re-reads the PaymentIntent from Stripe. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; +const GIB = 1024 * 1024 * 1024; +/** Days past due before a past_due account counts as restricted (design §3.11, N10 proposal). */ +export const RESTRICT_AFTER_DAYS = 7; + +/* ---------------- storage samples (additive table) ---------------- */ + +export interface StorageSample { + customerId: string; + peergosUsername: string; + bytes: number; + sampledAtMs: number; +} + +export interface StorageSampleRepo { + add(samples: StorageSample[]): void; + list(customerId: string, sinceMs: number): StorageSample[]; +} + +export class InMemoryStorageSampleRepo implements StorageSampleRepo { + private rows: StorageSample[] = []; + add(samples: StorageSample[]): void { + this.rows.push(...samples.map((s) => ({ ...s }))); + } + list(customerId: string, sinceMs: number): StorageSample[] { + return this.rows + .filter((r) => r.customerId === customerId && r.sampledAtMs >= sinceMs) + .sort((a, b) => a.sampledAtMs - b.sampledAtMs); + } +} + +export const STORAGE_SAMPLES_DDL = ` +CREATE TABLE IF NOT EXISTS storage_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + peergos_username TEXT NOT NULL, + bytes INTEGER NOT NULL, + sampled_at_ms INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_storage_samples_customer + ON storage_samples(customer_id, sampled_at_ms); +`; + +export class SqliteStorageSampleRepo implements StorageSampleRepo { + constructor(private db: Database.Database) { + db.exec(STORAGE_SAMPLES_DDL); + } + add(samples: StorageSample[]): void { + const stmt = this.db.prepare( + `INSERT INTO storage_samples (customer_id, peergos_username, bytes, sampled_at_ms) + VALUES (@customerId, @peergosUsername, @bytes, @sampledAtMs)`, + ); + const tx = this.db.transaction((rows: StorageSample[]) => rows.forEach((r) => stmt.run(r))); + tx(samples); + } + list(customerId: string, sinceMs: number): StorageSample[] { + return ( + this.db + .prepare( + `SELECT customer_id, peergos_username, bytes, sampled_at_ms FROM storage_samples + WHERE customer_id = ? AND sampled_at_ms >= ? ORDER BY sampled_at_ms`, + ) + .all(customerId, sinceMs) as Array> + ).map((r) => ({ + customerId: String(r.customer_id), + peergosUsername: String(r.peergos_username), + bytes: Number(r.bytes), + sampledAtMs: Number(r.sampled_at_ms), + })); + } +} + +/* ---------------- purchase orders (additive table) ---------------- */ + +export interface PurchaseOrder { + id: string; + customerId: string; + poNumber: string; + /** PO ceiling in cents; undefined = no ceiling recorded. */ + amountCents?: number; + startsAtMs: number; + expiresAtMs?: number; + netTermsDays: number; + status: 'active' | 'replaced' | 'cancelled'; + createdAtMs: number; + createdBy?: string; + note?: string; +} + +export interface PurchaseOrderRepo { + list(customerId: string): PurchaseOrder[]; + get(id: string): PurchaseOrder | undefined; + save(po: PurchaseOrder): void; +} + +export class InMemoryPurchaseOrderRepo implements PurchaseOrderRepo { + private rows = new Map(); + list(customerId: string): PurchaseOrder[] { + return [...this.rows.values()] + .filter((p) => p.customerId === customerId) + .sort((a, b) => b.startsAtMs - a.startsAtMs); + } + get(id: string): PurchaseOrder | undefined { + return this.rows.get(id); + } + save(po: PurchaseOrder): void { + this.rows.set(po.id, { ...po }); + } +} + +export const PURCHASE_ORDERS_DDL = ` +CREATE TABLE IF NOT EXISTS purchase_orders ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + po_number TEXT NOT NULL, + amount_cents INTEGER, + starts_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER, + net_terms_days INTEGER NOT NULL DEFAULT 30, + status TEXT NOT NULL DEFAULT 'active', + created_at_ms INTEGER NOT NULL, + created_by TEXT, + note TEXT +); +CREATE INDEX IF NOT EXISTS idx_purchase_orders_customer ON purchase_orders(customer_id); +`; + +export class SqlitePurchaseOrderRepo implements PurchaseOrderRepo { + constructor(private db: Database.Database) { + db.exec(PURCHASE_ORDERS_DDL); + } + private map(r: Record): PurchaseOrder { + return { + id: String(r.id), + customerId: String(r.customer_id), + poNumber: String(r.po_number), + amountCents: r.amount_cents == null ? undefined : Number(r.amount_cents), + startsAtMs: Number(r.starts_at_ms), + expiresAtMs: r.expires_at_ms == null ? undefined : Number(r.expires_at_ms), + netTermsDays: Number(r.net_terms_days), + status: String(r.status) as PurchaseOrder['status'], + createdAtMs: Number(r.created_at_ms), + createdBy: r.created_by == null ? undefined : String(r.created_by), + note: r.note == null ? undefined : String(r.note), + }; + } + list(customerId: string): PurchaseOrder[] { + return ( + this.db + .prepare(`SELECT * FROM purchase_orders WHERE customer_id = ? ORDER BY starts_at_ms DESC`) + .all(customerId) as Array> + ).map((r) => this.map(r)); + } + get(id: string): PurchaseOrder | undefined { + const r = this.db.prepare(`SELECT * FROM purchase_orders WHERE id = ?`).get(id) as + | Record + | undefined; + return r ? this.map(r) : undefined; + } + save(po: PurchaseOrder): void { + this.db + .prepare( + `INSERT INTO purchase_orders (id, customer_id, po_number, amount_cents, starts_at_ms, expires_at_ms, + net_terms_days, status, created_at_ms, created_by, note) + VALUES (@id, @customerId, @poNumber, @amountCents, @startsAtMs, @expiresAtMs, + @netTermsDays, @status, @createdAtMs, @createdBy, @note) + ON CONFLICT(id) DO UPDATE SET po_number = @poNumber, amount_cents = @amountCents, + starts_at_ms = @startsAtMs, expires_at_ms = @expiresAtMs, net_terms_days = @netTermsDays, + status = @status, note = @note`, + ) + .run({ + ...po, + amountCents: po.amountCents ?? null, + expiresAtMs: po.expiresAtMs ?? null, + createdBy: po.createdBy ?? null, + note: po.note ?? null, + }); + } +} + +/* ---------------- Stripe (injectable) ---------------- */ + +export interface CardSummary { + id: string; + brand: string; + last4: string; + expMonth: number; + expYear: number; +} + +export interface BillingStripe { + createCustomer(c: Customer): Promise; + listCards(stripeCustomerId: string): Promise<{ defaultId?: string; cards: CardSummary[] }>; + createSetupIntent(stripeCustomerId: string): Promise<{ id: string; clientSecret: string }>; + createEphemeralKey(stripeCustomerId: string): Promise; + paymentMethodOwner(pmId: string): Promise; + detach(pmId: string): Promise; + setDefault(stripeCustomerId: string, pmId: string): Promise; + createSetupCheckout(stripeCustomerId: string, successUrl: string, cancelUrl: string): Promise; + createInvoicePaymentIntent(args: { + stripeCustomerId: string; + amountCents: number; + invoiceId: string; + customerId: string; + }): Promise<{ id: string; clientSecret: string }>; + retrievePaymentIntent(id: string): Promise<{ + status: string; + amountReceived: number; + customer?: string; + metadata: Record; + }>; +} + +const STRIPE_API_VERSION_FOR_EPHEMERAL = '2024-06-20'; + +export function stripeBillingClient(secretKey: string): BillingStripe { + const stripe = new Stripe(secretKey); + return { + async createCustomer(c) { + const created = await stripe.customers.create({ + email: c.email, + name: c.name, + metadata: { customerId: c.id }, + }); + return created.id; + }, + async listCards(id) { + const [cust, pms] = await Promise.all([ + stripe.customers.retrieve(id), + stripe.paymentMethods.list({ customer: id, type: 'card', limit: 20 }), + ]); + const def = + !('deleted' in cust && cust.deleted) && typeof (cust as Stripe.Customer).invoice_settings?.default_payment_method === 'string' + ? ((cust as Stripe.Customer).invoice_settings.default_payment_method as string) + : undefined; + return { + defaultId: def, + cards: pms.data.map((pm) => ({ + id: pm.id, + brand: pm.card?.brand ?? 'card', + last4: pm.card?.last4 ?? '????', + expMonth: pm.card?.exp_month ?? 0, + expYear: pm.card?.exp_year ?? 0, + })), + }; + }, + async createSetupIntent(id) { + const si = await stripe.setupIntents.create({ + customer: id, + usage: 'off_session', + automatic_payment_methods: { enabled: true }, + }); + return { id: si.id, clientSecret: si.client_secret ?? '' }; + }, + async createEphemeralKey(id) { + const key = await stripe.ephemeralKeys.create( + { customer: id }, + { apiVersion: STRIPE_API_VERSION_FOR_EPHEMERAL }, + ); + return key.secret ?? undefined; + }, + async paymentMethodOwner(pmId) { + const pm = await stripe.paymentMethods.retrieve(pmId); + return typeof pm.customer === 'string' ? pm.customer : pm.customer?.id; + }, + async detach(pmId) { + await stripe.paymentMethods.detach(pmId); + }, + async setDefault(id, pmId) { + await stripe.customers.update(id, { invoice_settings: { default_payment_method: pmId } }); + }, + async createSetupCheckout(id, successUrl, cancelUrl) { + const s = await stripe.checkout.sessions.create({ + mode: 'setup', + customer: id, + payment_method_types: ['card'], + success_url: successUrl, + cancel_url: cancelUrl, + }); + return s.url ?? ''; + }, + async createInvoicePaymentIntent(a) { + const pi = await stripe.paymentIntents.create( + { + amount: a.amountCents, + currency: 'usd', + customer: a.stripeCustomerId, + automatic_payment_methods: { enabled: true }, + setup_future_usage: 'off_session', + metadata: { customerId: a.customerId, invoiceId: a.invoiceId, purpose: 'invoice' }, + }, + { idempotencyKey: `invoice-pay:${a.invoiceId}:${a.amountCents}:${Math.floor(Date.now() / 600000)}` }, + ); + return { id: pi.id, clientSecret: pi.client_secret ?? '' }; + }, + async retrievePaymentIntent(id) { + const pi = await stripe.paymentIntents.retrieve(id); + return { + status: pi.status, + amountReceived: pi.amount_received, + customer: typeof pi.customer === 'string' ? pi.customer : pi.customer?.id, + metadata: (pi.metadata ?? {}) as Record, + }; + }, + }; +} + +/* ---------------- pure computations ---------------- */ + +export interface Period { + id: string; + startMs: number; + endMs: number; + daysInMonth: number; + daysElapsed: number; +} + +export function currentPeriod(nowMs: number): Period { + const d = new Date(nowMs); + const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + const end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1); + const daysInMonth = Math.round((end - start) / DAY_MS); + const daysElapsed = Math.min(daysInMonth, Math.floor((nowMs - start) / DAY_MS) + 1); + const id = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; + return { id, startMs: start, endMs: end, daysInMonth, daysElapsed }; +} + +/** Pooled daily peak bytes: per user the max sample of each UTC day, summed across users. */ +export function dailyPooledPeaks(samples: StorageSample[], period: Period): Array<{ day: string; peakBytes: number; samples: number }> { + const perUserDay = new Map>(); + const counts = new Map(); + for (const s of samples) { + if (s.sampledAtMs < period.startMs || s.sampledAtMs >= period.endMs) continue; + const day = Math.floor((s.sampledAtMs - period.startMs) / DAY_MS); + const m = perUserDay.get(s.peergosUsername) ?? new Map(); + m.set(day, Math.max(m.get(day) ?? 0, s.bytes)); + perUserDay.set(s.peergosUsername, m); + counts.set(day, (counts.get(day) ?? 0) + 1); + } + const out: Array<{ day: string; peakBytes: number; samples: number }> = []; + for (let day = 0; day < period.daysElapsed; day++) { + let total = 0; + let any = false; + for (const m of perUserDay.values()) { + // carry the last known value forward for missing days (design §3.5) + let v: number | undefined; + for (let k = day; k >= 0; k--) { + if (m.has(k)) { + v = m.get(k); + break; + } + } + if (v !== undefined) { + total += v; + any = true; + } + } + if (!any) continue; + out.push({ + day: new Date(period.startMs + day * DAY_MS).toISOString().slice(0, 10), + peakBytes: total, + samples: counts.get(day) ?? 0, + }); + } + return out; +} + +export interface EstimateInput { + period: Period; + tier: TierConfig | undefined; + includedGbOverride?: number; + millicentsPerGb: number; + daily: Array<{ peakBytes: number }>; + currentBytes: number; + stampsCents: number; + baseCentsPerMonth: number; + prepaidCents: number; +} + +export function estimatePeriod(e: EstimateInput) { + const D = e.period.daysInMonth; + const elapsed = e.period.daysElapsed; + const includedGb = e.includedGbOverride ?? e.tier?.includedStorageGb ?? 0; + const gbDaysToDate = e.daily.reduce((s, d) => s + d.peakBytes / GIB, 0); + const allowanceToDate = includedGb * elapsed; + const overageToDateCents = Math.ceil( + (Math.max(0, gbDaysToDate - allowanceToDate) / D) * (e.millicentsPerGb / 1000), + ); + const projectedGbDays = gbDaysToDate + (D - elapsed) * (e.currentBytes / GIB); + const projectedOverageCents = Math.ceil( + (Math.max(0, projectedGbDays - includedGb * D) / D) * (e.millicentsPerGb / 1000), + ); + const baseToDateCents = Math.round((e.baseCentsPerMonth * elapsed) / D); + const credit = e.tier?.monthlyCreditCents ?? 0; + const totalToDate = e.stampsCents + baseToDateCents + overageToDateCents; + const creditToDate = Math.min(totalToDate, credit); + const billableToDate = Math.max(0, totalToDate - creditToDate); + const projectedStamps = elapsed > 0 ? Math.round((e.stampsCents * D) / elapsed) : e.stampsCents; + const projectedTotal = projectedStamps + e.baseCentsPerMonth + projectedOverageCents; + const projectedBillable = Math.max(0, projectedTotal - Math.min(projectedTotal, credit)); + const prepaidApplied = Math.min(Math.max(0, e.prepaidCents), billableToDate); + return { + period: { id: e.period.id, daysElapsed: elapsed, daysInMonth: D }, + prorated: true, + baseCents: baseToDateCents, + storage: { + includedGb, + millicentsPerGb: e.millicentsPerGb, + overageCentsPerGb: e.millicentsPerGb / 1000, + gbDaysToDate: round3(gbDaysToDate), + allowanceGbDaysToDate: round3(allowanceToDate), + avgGbToDate: round3(elapsed ? gbDaysToDate / elapsed : 0), + projectedAvgGb: round3(projectedGbDays / D), + overageCentsToDate: overageToDateCents, + projectedOverageCents, + }, + stampsCents: e.stampsCents, + creditCents: creditToDate, + monthlyCreditCents: credit, + totalCents: totalToDate, + estimateCents: billableToDate, + prepaidBalanceCents: e.prepaidCents, + prepaidAppliedCents: prepaidApplied, + amountDueAfterPrepaidCents: billableToDate - prepaidApplied, + projectedCents: projectedBillable, + }; +} + +function round3(n: number): number { + return Math.round(n * 1000) / 1000; +} + +export type InvoiceDisplayStatus = 'draft' | 'paid' | 'unpaid' | 'overdue'; + +export function invoiceDisplayStatus(inv: Invoice, nowMs: number): InvoiceDisplayStatus { + if (inv.status === 'paid') return 'paid'; + if (inv.status === 'draft') return 'draft'; + if (inv.billableCents <= 0) return 'paid'; + return inv.dueAtMs !== undefined && nowMs > inv.dueAtMs ? 'overdue' : 'unpaid'; +} + +export function accountStanding(invoices: Invoice[], nowMs: number): 'good' | 'past_due' | 'restricted' { + let worst: 'good' | 'past_due' | 'restricted' = 'good'; + for (const inv of invoices) { + if (invoiceDisplayStatus(inv, nowMs) !== 'overdue') continue; + const daysOver = (nowMs - (inv.dueAtMs ?? nowMs)) / DAY_MS; + if (daysOver > RESTRICT_AFTER_DAYS) return 'restricted'; + worst = 'past_due'; + } + return worst; +} + +/* ---------------- routers ---------------- */ + +export interface BillingApiDeps { + customers: CustomerRepo; + usage: UsageRepo; + invoices: InvoiceRepo; + tiers: () => TierConfig[]; + samples: StorageSampleRepo; + purchaseOrders: PurchaseOrderRepo; + /** Undefined when no real Stripe key is configured: card endpoints answer 503. */ + stripe?: BillingStripe; + publishableKey?: string; + pin?: StoragePinConfig; + now?: () => number; + portalBaseUrl?: string; +} + +const STORAGE_ENDPOINT_IDS = new Set(['storage-gb-month']); + +async function pooledUsers(deps: BillingApiDeps, customer: Customer): Promise { + return (await deps.customers.listChildren(customer.id)).filter((c) => c.accountKind === 'peergos_user' || !!c.parentId); +} + +function poView(po: PurchaseOrder, invoices: Invoice[], nowMs: number) { + const invoicedCents = invoices + .filter((i) => i.poNumber === po.poNumber && i.status !== 'draft') + .reduce((s, i) => s + i.billableCents, 0); + const expired = po.expiresAtMs !== undefined && nowMs > po.expiresAtMs; + return { + id: po.id, + poNumber: po.poNumber, + amountCents: po.amountCents ?? null, + invoicedCents, + remainingCents: po.amountCents === undefined ? null : po.amountCents - invoicedCents, + startsAt: new Date(po.startsAtMs).toISOString(), + expiresAt: po.expiresAtMs === undefined ? null : new Date(po.expiresAtMs).toISOString(), + netTermsDays: po.netTermsDays, + status: po.status === 'active' && expired ? 'expired' : po.status, + note: po.note ?? null, + }; +} + +export function billingRouter(deps: BillingApiDeps): Router { + const router = Router(); + const now = deps.now ?? Date.now; + const pin = deps.pin ?? DEFAULT_STORAGE_PIN; + + const tierOf = (c: Customer) => deps.tiers().find((t) => t.id === c.tierId); + + async function scopedUsage(customer: Customer, peergosUsername?: string) { + const period = currentPeriod(now()); + const users = await pooledUsers(deps, customer); + if (peergosUsername) { + const id = peergosCustomerId(peergosUsername); + const child = users.find((u) => u.id === id); + if (!child) return { error: 'peergos_user_not_linked' as const }; + } + const samples = deps.samples.list(customer.id, period.startMs); + const daily = dailyPooledPeaks(samples, period); + const latest = new Map(); + for (const s of samples) latest.set(s.peergosUsername, s); + const currentBytes = [...latest.values()].reduce((sum, s) => sum + s.bytes, 0); + const entries = await deps.usage.listFor(customer.id, new Date(period.startMs)); + const stampEntries = entries.filter((e) => !STORAGE_ENDPOINT_IDS.has(e.endpointId)); + return { period, users, samples, daily, latest, currentBytes, stampEntries }; + } + + router.get('/summary', async (req, res) => { + const c = req.customer!; + const tier = tierOf(c); + const invs = deps.invoices.list({ customerId: c.id }); + const period = currentPeriod(now()); + res.json({ + customer: { + id: c.id, + name: c.name, + email: c.email ?? null, + billingType: c.billingType ?? 'stripe', + balanceCents: c.balanceCents ?? 0, + hasStripeCustomer: !!c.stripeCustomerId, + }, + plan: tier + ? { + tierId: tier.id, + name: tier.name, + monthlyCreditCents: tier.monthlyCreditCents, + includedStorageGb: c.includedStorageGbOverride ?? tier.includedStorageGb ?? 0, + includedStampQuotaMonthly: tier.includedStampQuotaMonthly ?? 0, + baseCents: 0, + } + : null, + storageOverageCentsPerGb: hostedOverageMillicentsPerGb(pin) / 1000, + period: { + id: period.id, + start: new Date(period.startMs).toISOString(), + end: new Date(period.endMs).toISOString(), + daysElapsed: period.daysElapsed, + daysInMonth: period.daysInMonth, + }, + standing: accountStanding(invs, now()), + stripeConfigured: !!deps.stripe, + publishableKey: deps.publishableKey ?? null, + }); + }); + + router.get('/usage', async (req, res) => { + const c = req.customer!; + const u = typeof req.query.peergosUsername === 'string' ? req.query.peergosUsername : undefined; + const s = await scopedUsage(c, u); + if ('error' in s) { + res.status(404).json({ error: s.error }); + return; + } + const tier = tierOf(c); + const includedGb = c.includedStorageGbOverride ?? tier?.includedStorageGb ?? 0; + const stampsUsed = s.stampEntries.filter( + (e) => e.endpointId.startsWith('timestamp') && e.endpointId !== 'timestamp-verify', + ).length; + const byEndpoint: Record = {}; + for (const e of s.stampEntries) { + const b = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 }); + b.calls += 1; + b.cents += e.cents; + } + res.json({ + period: { id: s.period.id, daysElapsed: s.period.daysElapsed, daysInMonth: s.period.daysInMonth }, + storage: { + pooledBytes: s.currentBytes, + includedGb, + percentOfPlan: includedGb > 0 ? Math.round((s.currentBytes / GIB / includedGb) * 1000) / 10 : null, + users: s.users.map((child) => { + const name = child.id.replace(/^peergos:/, ''); + const last = s.latest.get(name); + return { username: name, bytes: last?.bytes ?? null, sampledAt: last ? new Date(last.sampledAtMs).toISOString() : null }; + }), + daily: s.daily, + source: s.samples.length ? 'samples' : 'none', + }, + stamps: { + used: stampsUsed, + included: tier?.includedStampQuotaMonthly ?? 0, + cents: s.stampEntries.reduce((sum, e) => sum + e.cents, 0), + byEndpoint, + }, + }); + }); + + router.get('/estimate', async (req, res) => { + const c = req.customer!; + const s = await scopedUsage(c); + if ('error' in s) { + res.status(404).json({ error: s.error }); + return; + } + res.json( + estimatePeriod({ + period: s.period, + tier: tierOf(c), + includedGbOverride: c.includedStorageGbOverride, + millicentsPerGb: c.storageOveragePerGbMillicentsOverride ?? hostedOverageMillicentsPerGb(pin), + daily: s.daily, + currentBytes: s.currentBytes, + stampsCents: s.stampEntries.reduce((sum, e) => sum + e.cents, 0), + baseCentsPerMonth: 0, + prepaidCents: c.balanceCents ?? 0, + }), + ); + }); + + router.get('/invoices', (req, res) => { + const c = req.customer!; + const t = now(); + const list = deps.invoices + .list({ customerId: c.id }) + .filter((i) => i.status !== 'draft') + .sort((a, b) => (b.issuedAtMs ?? 0) - (a.issuedAtMs ?? 0)) + .map((i) => { + const status = invoiceDisplayStatus(i, t); + return { + id: i.id, + period: i.period, + status, + totalCents: i.totalCents, + creditCents: i.creditCents, + billableCents: i.billableCents, + billingType: i.billingType, + poNumber: i.poNumber ?? null, + issuedAt: i.issuedAtMs ? new Date(i.issuedAtMs).toISOString() : null, + dueAt: i.dueAtMs ? new Date(i.dueAtMs).toISOString() : null, + paidAt: i.paidAtMs ? new Date(i.paidAtMs).toISOString() : null, + htmlPath: `/portal/api/invoices/${encodeURIComponent(i.id)}?format=html`, + payable: (status === 'unpaid' || status === 'overdue') && i.billingType === 'stripe', + }; + }); + res.json({ invoices: list, standing: accountStanding(deps.invoices.list({ customerId: c.id }), t) }); + }); + + async function ensureStripeCustomer(c: Customer): Promise { + if (c.stripeCustomerId) return c.stripeCustomerId; + const id = await deps.stripe!.createCustomer(c); + await deps.customers.save({ ...c, stripeCustomerId: id }); + return id; + } + + const needStripe: import('express').RequestHandler = (req, res, next) => { + if (!deps.stripe) { + res.status(503).json({ error: 'stripe_unavailable', message: 'Card payments are not configured on this server.' }); + return; + } + if ((req.customer!.billingType ?? 'stripe') !== 'stripe') { + res.status(409).json({ error: 'purchase_order_customer', message: 'This account is billed by purchase order.' }); + return; + } + next(); + }; + + router.post('/invoices/:id/pay', needStripe, async (req, res) => { + const c = req.customer!; + const inv = deps.invoices.get(req.params.id); + if (!inv || inv.customerId !== c.id) { + res.status(404).json({ error: 'invoice not found' }); + return; + } + const status = invoiceDisplayStatus(inv, now()); + if (status !== 'unpaid' && status !== 'overdue') { + res.status(409).json({ error: `invoice is ${status}` }); + return; + } + const sc = await ensureStripeCustomer(c); + const pi = await deps.stripe!.createInvoicePaymentIntent({ + stripeCustomerId: sc, + amountCents: inv.billableCents, + invoiceId: inv.id, + customerId: c.id, + }); + const ek = await deps.stripe!.createEphemeralKey(sc); + res.json({ + mode: 'stripe', + invoiceId: inv.id, + amountCents: inv.billableCents, + paymentIntentId: pi.id, + clientSecret: pi.clientSecret, + stripeCustomerId: sc, + ephemeralKeySecret: ek ?? null, + publishableKey: deps.publishableKey ?? null, + }); + }); + + router.post('/invoices/:id/pay/confirm', needStripe, async (req, res) => { + const c = req.customer!; + const inv = deps.invoices.get(req.params.id); + const piId = req.body?.paymentIntentId; + if (!inv || inv.customerId !== c.id) { + res.status(404).json({ error: 'invoice not found' }); + return; + } + if (typeof piId !== 'string' || !piId.startsWith('pi_')) { + res.status(400).json({ error: 'paymentIntentId required' }); + return; + } + if (inv.status === 'paid') { + res.json({ ok: true, status: 'paid', invoiceId: inv.id }); + return; + } + const pi = await deps.stripe!.retrievePaymentIntent(piId); + const ok = + pi.status === 'succeeded' && + pi.metadata.invoiceId === inv.id && + pi.metadata.customerId === c.id && + (!c.stripeCustomerId || pi.customer === c.stripeCustomerId) && + pi.amountReceived >= inv.billableCents; + if (!ok) { + res.status(409).json({ error: 'payment_not_confirmed', paymentStatus: pi.status }); + return; + } + deps.invoices.save({ ...inv, status: 'paid', paidAtMs: now() }); + res.json({ ok: true, status: 'paid', invoiceId: inv.id }); + }); + + router.get('/payment-methods', needStripe, async (req, res) => { + const c = req.customer!; + if (!c.stripeCustomerId) { + res.json({ defaultPaymentMethodId: null, paymentMethods: [] }); + return; + } + const r = await deps.stripe!.listCards(c.stripeCustomerId); + res.json({ defaultPaymentMethodId: r.defaultId ?? null, paymentMethods: r.cards }); + }); + + router.post('/payment-methods/setup-intent', needStripe, async (req, res) => { + const sc = await ensureStripeCustomer(req.customer!); + const si = await deps.stripe!.createSetupIntent(sc); + const ek = await deps.stripe!.createEphemeralKey(sc); + res.json({ + setupIntentId: si.id, + clientSecret: si.clientSecret, + stripeCustomerId: sc, + ephemeralKeySecret: ek ?? null, + publishableKey: deps.publishableKey ?? null, + }); + }); + + router.post('/payment-methods/checkout-session', needStripe, async (req, res) => { + const sc = await ensureStripeCustomer(req.customer!); + const base = deps.portalBaseUrl ?? 'https://zap.verae.ai/portal/'; + const url = await deps.stripe!.createSetupCheckout(sc, `${base}#billing?setup=done`, `${base}#billing?setup=cancelled`); + res.json({ url }); + }); + + async function ownedPm(c: Customer, pmId: string): Promise { + if (!c.stripeCustomerId || !/^(pm|card)_/.test(pmId)) return false; + return (await deps.stripe!.paymentMethodOwner(pmId)) === c.stripeCustomerId; + } + + router.delete('/payment-methods/:id', needStripe, async (req, res) => { + if (!(await ownedPm(req.customer!, req.params.id))) { + res.status(404).json({ error: 'payment method not found' }); + return; + } + await deps.stripe!.detach(req.params.id); + res.json({ ok: true }); + }); + + router.post('/payment-methods/:id/default', needStripe, async (req, res) => { + if (!(await ownedPm(req.customer!, req.params.id))) { + res.status(404).json({ error: 'payment method not found' }); + return; + } + await deps.stripe!.setDefault(req.customer!.stripeCustomerId!, req.params.id); + res.json({ ok: true, defaultPaymentMethodId: req.params.id }); + }); + + router.get('/po', (req, res) => { + const c = req.customer!; + const t = now(); + const invs = deps.invoices.list({ customerId: c.id }); + const pos = deps.purchaseOrders.list(c.id).map((p) => poView(p, invs, t)); + const current = pos.find((p) => p.status === 'active') ?? null; + res.json({ billingType: c.billingType ?? 'stripe', current, purchaseOrders: pos }); + }); + + return router; +} + +/** Staff routes, mounted under /admin/api (admin auth already applied). */ +export function billingAdminRouter(deps: BillingApiDeps): Router { + const router = Router(); + const now = deps.now ?? Date.now; + + // Sampler (design §3.2): POST { samples: [{ peergosUsername, bytes, sampledAt? }] } + router.post('/peergos/storage/samples', async (req, res) => { + const raw = Array.isArray(req.body?.samples) ? req.body.samples : null; + if (!raw || raw.length === 0 || raw.length > 10000) { + res.status(400).json({ error: 'samples[] (1-10000) required' }); + return; + } + const accepted: StorageSample[] = []; + const unlinked: string[] = []; + for (const s of raw) { + const name = typeof s?.peergosUsername === 'string' ? s.peergosUsername : ''; + const bytes = Number(s?.bytes); + if (!name || !Number.isFinite(bytes) || bytes < 0) { + res.status(400).json({ error: 'each sample needs peergosUsername and bytes >= 0' }); + return; + } + const child = await deps.customers.findById(peergosCustomerId(name)); + if (!child || !child.parentId) { + unlinked.push(name); + continue; + } + const at = s.sampledAt ? Date.parse(String(s.sampledAt)) : now(); + accepted.push({ + customerId: child.parentId, + peergosUsername: name, + bytes: Math.round(bytes), + sampledAtMs: Number.isFinite(at) ? at : now(), + }); + } + if (accepted.length) deps.samples.add(accepted); + res.json({ accepted: accepted.length, unlinked }); + }); + + router.get('/customers/:id/purchase-orders', async (req, res) => { + const c = await deps.customers.findById(req.params.id); + if (!c) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const invs = deps.invoices.list({ customerId: c.id }); + res.json({ purchaseOrders: deps.purchaseOrders.list(c.id).map((p) => poView(p, invs, now())) }); + }); + + function parsePoBody(body: Record) { + const poNumber = typeof body.poNumber === 'string' ? body.poNumber.trim() : ''; + const amountCents = body.amountCents == null ? undefined : Number(body.amountCents); + const netTermsDays = body.netTermsDays == null ? 30 : Number(body.netTermsDays); + const startsAtMs = body.startsAt ? Date.parse(String(body.startsAt)) : now(); + const expiresAtMs = body.expiresAt ? Date.parse(String(body.expiresAt)) : undefined; + if ( + !poNumber || + (amountCents !== undefined && (!Number.isInteger(amountCents) || amountCents < 0)) || + !Number.isInteger(netTermsDays) || netTermsDays < 0 || netTermsDays > 365 || + !Number.isFinite(startsAtMs) || + (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) + ) { + return undefined; + } + return { poNumber, amountCents, netTermsDays, startsAtMs, expiresAtMs, note: typeof body.note === 'string' ? body.note : undefined }; + } + + // Attach a PO (replaces any active one). Body: { poNumber, amountCents?, netTermsDays?, startsAt?, expiresAt?, note? } + router.post('/customers/:id/purchase-orders', async (req, res) => { + const c = await deps.customers.findById(req.params.id); + if (!c) { + res.status(404).json({ error: 'customer not found' }); + return; + } + const p = parsePoBody(req.body ?? {}); + if (!p) { + res.status(400).json({ error: 'poNumber required; amountCents/netTermsDays integers; dates ISO' }); + return; + } + for (const old of deps.purchaseOrders.list(c.id).filter((x) => x.status === 'active')) { + deps.purchaseOrders.save({ ...old, status: 'replaced' }); + } + const po: PurchaseOrder = { + id: `po_${randomBytes(8).toString('hex')}`, + customerId: c.id, + ...p, + status: 'active', + createdAtMs: now(), + createdBy: req.staff?.username ?? 'admin', + }; + deps.purchaseOrders.save(po); + res.status(201).json(poView(po, deps.invoices.list({ customerId: c.id }), now())); + }); + + // Renew: extend expiry and/or raise the ceiling of an existing PO. + router.post('/customers/:id/purchase-orders/:poId/renew', async (req, res) => { + const po = deps.purchaseOrders.get(req.params.poId); + if (!po || po.customerId !== req.params.id) { + res.status(404).json({ error: 'purchase order not found' }); + return; + } + const expiresAtMs = req.body?.expiresAt ? Date.parse(String(req.body.expiresAt)) : po.expiresAtMs; + const amountCents = req.body?.amountCents == null ? po.amountCents : Number(req.body.amountCents); + if ((expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) || (amountCents !== undefined && (!Number.isInteger(amountCents) || amountCents < 0))) { + res.status(400).json({ error: 'expiresAt must be ISO; amountCents a non-negative integer' }); + return; + } + const updated: PurchaseOrder = { ...po, expiresAtMs, amountCents, status: 'active' }; + deps.purchaseOrders.save(updated); + res.json(poView(updated, deps.invoices.list({ customerId: po.customerId }), now())); + }); + + return router; +} diff --git a/src/index.ts b/src/index.ts index d610734..e3350f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,11 @@ import { SqliteApiTokenRepo } from './db/api-token-repo'; import { ensurePlatformOrg } from './peergos-billing'; import { PROJECT_ROOT } from './paths'; import { PaymentClient } from './portal'; +import { + SqlitePurchaseOrderRepo, + SqliteStorageSampleRepo, + stripeBillingClient, +} from './billing-api'; loadEnv({ path: path.join(PROJECT_ROOT, '.env') }); @@ -51,6 +56,12 @@ async function main(): Promise { const credits = new SqliteCreditLedger(opened.billingDb); // Long-lived xVerae- tokens live on auth DB (alongside portal sessions). const apiTokens = new SqliteApiTokenRepo(authDb); + // Usage & Billing API: additive tables on billing.db (CREATE TABLE IF NOT EXISTS). + const storageSamples = new SqliteStorageSampleRepo(opened.billingDb); + const purchaseOrders = new SqlitePurchaseOrderRepo(opened.billingDb); + const billingStripe = hasRealStripeKey(process.env.STRIPE_SECRET_KEY) + ? stripeBillingClient(process.env.STRIPE_SECRET_KEY) + : undefined; const { app } = buildApp({ usage, @@ -63,6 +74,10 @@ async function main(): Promise { channelRates, credits, apiTokens, + storageSamples, + purchaseOrders, + billingStripe, + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY, }); const port = Number(process.env.PORT ?? 3000); const bind = process.env.BIND ?? '127.0.0.1'; diff --git a/src/portal.ts b/src/portal.ts index 746be7f..c9349a5 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -24,6 +24,7 @@ import { PasswordResetStore, RateLimiter } from './password-reset'; import { codeEmail, maskEmail, passwordChangedEmail, verdictError } from './email-codes'; import { booksConfigured, ledgerAdjust, ledgerStatement } from './ledger'; import { grantSignupCredit } from './billing/signup-credit'; +import { BillingApiDeps, billingRouter } from './billing-api'; /** * Customer portal API (/portal/api): signup, login with optional TOTP 2FA, @@ -61,6 +62,8 @@ export interface PortalDeps { passwordResets?: PasswordResetStore; /** Codes for the signed-in password change flow (keyed by customer id). */ passwordChanges?: PasswordResetStore; + /** Usage & Billing API (/portal/api/billing/*); omitted = routes not mounted. */ + billing?: Omit; } const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -348,6 +351,20 @@ export function portalRouter(deps: PortalDeps): Router { router.use(sessionAuth(deps)); + if (deps.billing) { + router.use( + '/billing', + billingRouter({ + ...deps.billing, + customers: deps.customers, + usage: deps.usage, + invoices: deps.invoices, + tiers: deps.tiers, + now: deps.billing.now ?? deps.now, + }), + ); + } + router.post('/logout', (req, res) => { const bearer = req.header('authorization')!; deps.sessions.delete(bearer.slice(7)); diff --git a/tests/billing-api.test.ts b/tests/billing-api.test.ts new file mode 100644 index 0000000..205b404 --- /dev/null +++ b/tests/billing-api.test.ts @@ -0,0 +1,257 @@ +import request from 'supertest'; +import { buildApp } from '../src/app'; +import { + BillingStripe, + currentPeriod, + dailyPooledPeaks, + estimatePeriod, + InMemoryPurchaseOrderRepo, + InMemoryStorageSampleRepo, + invoiceDisplayStatus, +} from '../src/billing-api'; +import { InMemoryInvoiceRepo, Invoice } from '../src/invoicing'; +import { DEFAULT_TIERS } from '../src/pricing'; +import { PaymentClient } from '../src/portal'; + +const GIB = 1024 ** 3; +const devPayments: PaymentClient = { + reload: async (_c, amountCents) => ({ mode: 'dev' as const, creditedCents: amountCents }), +}; + +function fakeStripe() { + const calls: string[] = []; + const owners: Record = { pm_mine: 'cus_test1', pm_other: 'cus_someone_else' }; + let piStatus = 'requires_payment_method'; + const s: BillingStripe & { calls: string[]; setPiStatus(v: string): void } = { + calls, + setPiStatus(v) { + piStatus = v; + }, + async createCustomer() { + calls.push('createCustomer'); + return 'cus_test1'; + }, + async listCards() { + return { defaultId: 'pm_mine', cards: [{ id: 'pm_mine', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2030 }] }; + }, + async createSetupIntent() { + calls.push('createSetupIntent'); + return { id: 'seti_1', clientSecret: 'seti_1_secret_x' }; + }, + async createEphemeralKey() { + return 'ek_test_1'; + }, + async paymentMethodOwner(id) { + return owners[id]; + }, + async detach(id) { + calls.push(`detach:${id}`); + }, + async setDefault(_c, id) { + calls.push(`default:${id}`); + }, + async createSetupCheckout() { + return 'https://checkout.stripe.com/c/pay/cs_test_1'; + }, + async createInvoicePaymentIntent(a) { + calls.push(`pi:${a.invoiceId}:${a.amountCents}`); + return { id: 'pi_1', clientSecret: 'pi_1_secret_x' }; + }, + async retrievePaymentIntent() { + return { status: piStatus, amountReceived: piStatus === 'succeeded' ? 1234 : 0, customer: 'cus_test1', metadata: { invoiceId: 'INV-2026-09-0001', customerId: '' } }; + }, + }; + return s; +} + +async function setup(opts: { stripe?: BillingStripe; now?: number } = {}) { + const samples = new InMemoryStorageSampleRepo(); + const purchaseOrders = new InMemoryPurchaseOrderRepo(); + const invoices = new InMemoryInvoiceRepo(); + const built = buildApp({ + payments: devPayments, + qr: async () => 'data:', + storageSamples: samples, + purchaseOrders, + invoices, + billingStripe: opts.stripe, + }); + const signup = await request(built.app) + .post('/portal/api/signup') + .send({ name: 'Ada', email: 'ada@example.com', password: 'super-secret-1' }); + expect(signup.status).toBe(201); + return { ...built, samples, purchaseOrders, invoicesRepo: invoices, token: signup.body.token as string, customerId: signup.body.customer.id as string }; +} + +const auth = (t: string) => ({ authorization: `Bearer ${t}` }); + +describe('billing api: pure helpers', () => { + it('computes daily pooled peaks with carry-forward and a prorated estimate', () => { + const now = Date.UTC(2026, 9, 3, 12); // Oct 3 → 3 days elapsed of 31 + const p = currentPeriod(now); + expect(p).toMatchObject({ id: '2026-10', daysInMonth: 31, daysElapsed: 3 }); + const daily = dailyPooledPeaks( + [ + { customerId: 'c', peergosUsername: 'a', bytes: 2 * GIB, sampledAtMs: Date.UTC(2026, 9, 1, 1) }, + { customerId: 'c', peergosUsername: 'a', bytes: 4 * GIB, sampledAtMs: Date.UTC(2026, 9, 1, 5) }, + { customerId: 'c', peergosUsername: 'b', bytes: 1 * GIB, sampledAtMs: Date.UTC(2026, 9, 2, 5) }, + ], + p, + ); + expect(daily.map((d) => d.peakBytes / GIB)).toEqual([4, 5, 5]); + const free = DEFAULT_TIERS.find((t) => t.id === 'free')!; + const est = estimatePeriod({ + period: p, + tier: free, + millicentsPerGb: 2875, + daily, + currentBytes: 5 * GIB, + stampsCents: 30, + baseCentsPerMonth: 0, + prepaidCents: 0, + }); + // gbDays 14 − allowance 3 = 11 → 11/31 × 2.875¢ = 1.02 → ceil 2¢ + expect(est.storage.overageCentsToDate).toBe(2); + expect(est.totalCents).toBe(32); + expect(est.creditCents).toBe(32); // free $1 credit covers it (D2) + expect(est.estimateCents).toBe(0); + expect(est.storage.overageCentsPerGb).toBe(2.875); + }); + + it('maps invoice states to paid/unpaid/overdue', () => { + const base: Invoice = { + id: 'X', customerId: 'c', period: '2026-09', status: 'issued', lines: [], totalCents: 500, + creditCents: 0, billableCents: 500, billingType: 'stripe', issuedAtMs: 1, dueAtMs: 1000, + }; + expect(invoiceDisplayStatus(base, 500)).toBe('unpaid'); + expect(invoiceDisplayStatus(base, 2000)).toBe('overdue'); + expect(invoiceDisplayStatus({ ...base, status: 'paid' }, 2000)).toBe('paid'); + }); +}); + +describe('billing api: routes', () => { + it('requires a portal session', async () => { + const { app } = await setup(); + const r = await request(app).get('/portal/api/billing/summary'); + expect(r.status).toBe(401); + }); + + it('returns summary, usage, estimate and PO info for the signed-in customer', async () => { + const { app, token, customers, customerId } = await setup(); + const sum = await request(app).get('/portal/api/billing/summary').set(auth(token)); + expect(sum.status).toBe(200); + expect(sum.body.plan).toMatchObject({ tierId: 'free', includedStorageGb: 1, monthlyCreditCents: 100 }); + expect(sum.body.storageOverageCentsPerGb).toBe(2.875); + expect(sum.body.standing).toBe('good'); + expect(sum.body.stripeConfigured).toBe(false); + + // link a Peergos child and post a sample through the admin route + await customers.save({ id: 'peergos:ada', name: 'Peergos ada', tierId: 'free', apiKey: 'key-x', parentId: customerId, accountKind: 'peergos_user' }); + const adminKey = process.env.ADMIN_KEY ?? 'admin-dev-key'; + const post = await request(app) + .post('/admin/api/peergos/storage/samples') + .set('x-admin-key', adminKey) + .send({ samples: [{ peergosUsername: 'ada', bytes: 3 * GIB }, { peergosUsername: 'ghost', bytes: 1 }] }); + expect(post.status).toBe(200); + expect(post.body).toEqual({ accepted: 1, unlinked: ['ghost'] }); + + const usage = await request(app).get('/portal/api/billing/usage?peergosUsername=ada').set(auth(token)); + expect(usage.status).toBe(200); + expect(usage.body.storage.pooledBytes).toBe(3 * GIB); + expect(usage.body.storage.percentOfPlan).toBe(300); + expect(usage.body.storage.users[0]).toMatchObject({ username: 'ada', bytes: 3 * GIB }); + expect(usage.body.stamps.included).toBe(20); + + const notMine = await request(app).get('/portal/api/billing/usage?peergosUsername=bob').set(auth(token)); + expect(notMine.status).toBe(404); + + const est = await request(app).get('/portal/api/billing/estimate').set(auth(token)); + expect(est.status).toBe(200); + expect(est.body.prorated).toBe(true); + expect(est.body.prepaidBalanceCents).toBe(5000); + + const po = await request(app).get('/portal/api/billing/po').set(auth(token)); + expect(po.body).toEqual({ billingType: 'stripe', current: null, purchaseOrders: [] }); + }); + + it('card routes answer 503 without Stripe and never call Stripe', async () => { + const { app, token } = await setup(); + const r = await request(app).post('/portal/api/billing/payment-methods/setup-intent').set(auth(token)); + expect(r.status).toBe(503); + }); + + it('creates a SetupIntent, lists, detaches and sets default only for owned cards', async () => { + const stripe = fakeStripe(); + const { app, token, customers, customerId } = await setup({ stripe }); + const si = await request(app).post('/portal/api/billing/payment-methods/setup-intent').set(auth(token)); + expect(si.status).toBe(200); + expect(si.body).toMatchObject({ clientSecret: 'seti_1_secret_x', stripeCustomerId: 'cus_test1', ephemeralKeySecret: 'ek_test_1' }); + expect((await customers.findById(customerId))?.stripeCustomerId).toBe('cus_test1'); + const list = await request(app).get('/portal/api/billing/payment-methods').set(auth(token)); + expect(list.body.paymentMethods[0].last4).toBe('4242'); + expect((await request(app).delete('/portal/api/billing/payment-methods/pm_other').set(auth(token))).status).toBe(404); + expect((await request(app).delete('/portal/api/billing/payment-methods/pm_mine').set(auth(token))).status).toBe(200); + expect((await request(app).post('/portal/api/billing/payment-methods/pm_mine/default').set(auth(token))).status).toBe(200); + expect(stripe.calls).toEqual(['createCustomer', 'createSetupIntent', 'detach:pm_mine', 'default:pm_mine']); + }); + + it('pay-now creates an intent and only marks paid after Stripe confirms', async () => { + const stripe = fakeStripe(); + const { app, token, invoicesRepo, customerId } = await setup({ stripe }); + invoicesRepo.save({ + id: 'INV-2026-09-0001', customerId, period: '2026-09', status: 'issued', lines: [], totalCents: 1334, + creditCents: 100, billableCents: 1234, billingType: 'stripe', issuedAtMs: Date.now() - 1000, + }); + const list = await request(app).get('/portal/api/billing/invoices').set(auth(token)); + expect(list.body.invoices[0]).toMatchObject({ status: 'unpaid', payable: true }); + const pay = await request(app).post('/portal/api/billing/invoices/INV-2026-09-0001/pay').set(auth(token)); + expect(pay.status).toBe(200); + expect(pay.body).toMatchObject({ paymentIntentId: 'pi_1', amountCents: 1234 }); + // fake PI metadata customerId is '' → mismatch → not confirmed + const early = await request(app).post('/portal/api/billing/invoices/INV-2026-09-0001/pay/confirm').set(auth(token)).send({ paymentIntentId: 'pi_1' }); + expect(early.status).toBe(409); + expect(invoicesRepo.get('INV-2026-09-0001')?.status).toBe('issued'); + }); + + it('lets staff attach and renew a purchase order', async () => { + const { app, token, customerId, customers } = await setup(); + const c = await customers.findById(customerId); + await customers.save({ ...c!, billingType: 'purchase_order' }); + const adminKey = process.env.ADMIN_KEY ?? 'admin-dev-key'; + const created = await request(app) + .post(`/admin/api/customers/${customerId}/purchase-orders`) + .set('x-admin-key', adminKey) + .send({ poNumber: 'PO-77', amountCents: 100000, netTermsDays: 45, expiresAt: '2030-01-01T00:00:00Z' }); + expect(created.status).toBe(201); + expect(created.body).toMatchObject({ poNumber: 'PO-77', remainingCents: 100000, netTermsDays: 45, status: 'active' }); + const renewed = await request(app) + .post(`/admin/api/customers/${customerId}/purchase-orders/${created.body.id}/renew`) + .set('x-admin-key', adminKey) + .send({ expiresAt: '2031-01-01T00:00:00Z', amountCents: 200000 }); + expect(renewed.body.remainingCents).toBe(200000); + const po = await request(app).get('/portal/api/billing/po').set(auth(token)); + expect(po.body.billingType).toBe('purchase_order'); + expect(po.body.current.poNumber).toBe('PO-77'); + const card = await request(app).get('/portal/api/billing/payment-methods').set(auth(token)); + expect(card.status).toBe(503); + }); +}); + +describe('billing api: sqlite stores are additive', () => { + it('creates storage_samples and purchase_orders with IF NOT EXISTS and round-trips rows', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Database = require('better-sqlite3'); + const { SqliteStorageSampleRepo, SqlitePurchaseOrderRepo } = require('../src/billing-api'); + const db = new Database(':memory:'); + db.exec('CREATE TABLE customers (id TEXT PRIMARY KEY)'); + const s1 = new SqliteStorageSampleRepo(db); + new SqliteStorageSampleRepo(db); // idempotent + s1.add([{ customerId: 'c', peergosUsername: 'a', bytes: 5, sampledAtMs: 10 }]); + expect(s1.list('c', 0)).toEqual([{ customerId: 'c', peergosUsername: 'a', bytes: 5, sampledAtMs: 10 }]); + const po = new SqlitePurchaseOrderRepo(db); + po.save({ id: 'po_1', customerId: 'c', poNumber: 'P1', startsAtMs: 1, netTermsDays: 30, status: 'active', createdAtMs: 1 }); + expect(po.get('po_1')).toMatchObject({ poNumber: 'P1', amountCents: undefined }); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r: { name: string }) => r.name); + expect(tables).toEqual(expect.arrayContaining(['customers', 'storage_samples', 'purchase_orders'])); + }); +}); -- 2.54.0