Merge pull request 'feat(billing): Usage & Billing API for the peergos-flutter app (+ commit live ns1 drift)' (#2) from feat/usage-billing-api into overdue-negative-balance-only

This commit is contained in:
marchon 2026-10-06 08:26:15 -09:00
commit 700e9820fd
10 changed files with 1867 additions and 12 deletions

49
docs/USAGE-BILLING-API.md Normal file
View file

@ -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 <token>`, 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:<username>` 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.

View file

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

View file

@ -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();
}

View file

@ -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')));

936
src/billing-api.ts Normal file
View file

@ -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:<username>` 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<Record<string, unknown>>
).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<string, PurchaseOrder>();
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<string, unknown>): 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<Record<string, unknown>>
).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<string, unknown>
| 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<string>;
listCards(stripeCustomerId: string): Promise<{ defaultId?: string; cards: CardSummary[] }>;
createSetupIntent(stripeCustomerId: string): Promise<{ id: string; clientSecret: string }>;
createEphemeralKey(stripeCustomerId: string): Promise<string | undefined>;
paymentMethodOwner(pmId: string): Promise<string | undefined>;
detach(pmId: string): Promise<void>;
setDefault(stripeCustomerId: string, pmId: string): Promise<void>;
createSetupCheckout(stripeCustomerId: string, successUrl: string, cancelUrl: string): Promise<string>;
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<string, string>;
}>;
}
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<string, string>,
};
},
};
}
/* ---------------- 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<string, Map<number, number>>();
const counts = new Map<number, number>();
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<number, number>();
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<Customer[]> {
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<string, StorageSample>();
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<string, { calls: number; cents: number }> = {};
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<string> {
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<boolean> {
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<string, unknown>) {
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;
}

View file

@ -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<void> {
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<void> {
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';

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${escHtml(subject)}</title>
</head>
<body style="margin:0;padding:0;background:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;box-shadow:0 2px 8px rgba(0,0,0,0.04);">
<tr>
<td style="background:#0b1220;padding:20px 28px;text-align:left;">
<img src="${logo}" alt="Verae" width="160" style="display:block;max-width:160px;height:auto;border:0;"/>
</td>
</tr>
<tr>
<td style="padding:28px 28px 8px;">
<p style="margin:0;font-size:12px;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;font-weight:700;">Payment request / invoice</p>
<h1 style="margin:6px 0 0;font-size:22px;line-height:1.3;color:#111827;">Balance due: ${dueStr}</h1>
<p style="margin:8px 0 0;font-size:14px;color:#4b5563;">Account <strong style="color:#111827;">${custId}</strong> · Issued ${escHtml(issued)}</p>
</td>
</tr>
<tr>
<td style="padding:8px 28px 0;">
<p style="margin:0;font-size:15px;line-height:1.55;color:#374151;">Hello ${name},</p>
<p style="margin:12px 0 0;font-size:15px;line-height:1.55;color:#374151;">
Your Verae timestamping account has a negative prepaid balance. Timestamps keep working while the balance is negative — please reload at your convenience.
</p>
</td>
</tr>
<tr>
<td style="padding:20px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<tr style="background:#f9fafb;">
<th align="left" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Description</th>
<th align="right" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Amount</th>
</tr>
<tr>
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Prepaid balance</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${prepaidStr}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Unpaid issued invoices${escHtml(invNote)}</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${unpaidStr}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Usage not yet invoiced</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${unbilledStr}</td>
</tr>
<tr style="background:#fff7ed;">
<td style="padding:14px;font-size:15px;font-weight:700;color:#9a3412;">Effective balance</td>
<td align="right" style="padding:14px;font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;color:#9a3412;">${effectiveStr}</td>
</tr>
<tr style="background:#0b1220;">
<td style="padding:14px;font-size:15px;font-weight:700;color:#ffffff;">Amount due</td>
<td align="right" style="padding:14px;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums;color:#ffffff;">${dueStr}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:4px 28px 24px;" align="center">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
<tr>
<td style="padding:6px;">
<a href="${login}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Log in to portal</a>
</td>
<td style="padding:6px;">
<a href="${billing}" style="display:inline-block;background:#059669;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Reload balance</a>
</td>
</tr>
</table>
<p style="margin:14px 0 0;font-size:12px;color:#6b7280;line-height:1.5;">
After sign-in, open the <strong>Billing</strong> tab to reload.<br/>
Portal: <a href="${login}" style="color:#2563eb;">${login}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:13px;color:#6b7280;line-height:1.5;">Questions? Just reply to this email.</p>
<p style="margin:8px 0 0;font-size:13px;color:#374151;font-weight:600;">— Verae</p>
<p style="margin:12px 0 0;font-size:11px;color:#9ca3af;">
<img src="${logo}" alt="" width="72" style="vertical-align:middle;max-width:72px;height:auto;border:0;opacity:0.85;"/>
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
return { subject, text, html };
}
export async function sendPaymentRequests(
@ -239,7 +368,14 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
}
})(),
};
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));

383
src/jobs/send-invoices.ts Normal file
View file

@ -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<CustomerRepo, 'findById'>;
invoices: Pick<InvoiceRepo, 'get' | 'list'>;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** True only for issued invoices that have a positive amount due. */
export function isEmailableInvoice(inv: Pick<Invoice, 'status' | 'billableCents'>): boolean {
return inv.status === 'issued' && Number.isFinite(inv.billableCents) && inv.billableCents > 0;
}
export function invoiceEmail(
invoice: Invoice,
customer: Pick<Customer, 'id' | 'name' | 'email'>,
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) => `
<tr>
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">${escHtml(l.endpointId)}</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${l.calls}</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${escHtml(usd(l.cents))}</td>
</tr>`,
)
.join('');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${escHtml(subject)}</title>
</head>
<body style="margin:0;padding:0;background:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;box-shadow:0 2px 8px rgba(0,0,0,0.04);">
<tr>
<td style="background:#0b1220;padding:20px 28px;text-align:left;">
<img src="${logo}" alt="Verae" width="160" style="display:block;max-width:160px;height:auto;border:0;"/>
</td>
</tr>
<tr>
<td style="padding:28px 28px 8px;">
<p style="margin:0;font-size:12px;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;font-weight:700;">Invoice</p>
<h1 style="margin:6px 0 0;font-size:22px;line-height:1.3;color:#111827;">${invId}</h1>
<p style="margin:8px 0 0;font-size:14px;color:#4b5563;">
Account <strong style="color:#111827;">${custId}</strong>
· Period ${period}
· Issued ${issuedEsc}
· Due ${dueDateEsc}
· <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef0fe;color:#4f46e5;font-weight:700;font-size:12px;">${status}</span>
</p>
</td>
</tr>
<tr>
<td style="padding:8px 28px 0;">
<p style="margin:0;font-size:15px;line-height:1.55;color:#374151;">Hello ${name},</p>
<p style="margin:12px 0 0;font-size:15px;line-height:1.55;color:#374151;">
Here is your Verae timestamping invoice for period <strong>${period}</strong>. Amount due: <strong>${dueStr}</strong>.
</p>
</td>
</tr>
<tr>
<td style="padding:20px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<tr style="background:#f9fafb;">
<th align="left" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Endpoint</th>
<th align="right" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Calls</th>
<th align="right" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Amount</th>
</tr>
${rows || `<tr><td colspan="3" style="padding:12px 14px;font-size:14px;color:#6b7280;">No line items</td></tr>`}
<tr>
<td colspan="2" style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Usage total</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${totalStr}</td>
</tr>
<tr>
<td colspan="2" style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Monthly credit</td>
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">−${creditStr}</td>
</tr>
<tr style="background:#0b1220;">
<td colspan="2" style="padding:14px;font-size:15px;font-weight:700;color:#ffffff;">Amount due</td>
<td align="right" style="padding:14px;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums;color:#ffffff;">${dueStr}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:4px 28px 24px;" align="center">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
<tr>
<td style="padding:6px;">
<a href="${login}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Log in to portal</a>
</td>
<td style="padding:6px;">
<a href="${billing}" style="display:inline-block;background:#059669;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Pay / Billing</a>
</td>
</tr>
</table>
<p style="margin:14px 0 0;font-size:12px;color:#6b7280;line-height:1.5;">
After sign-in, open the <strong>Billing</strong> tab to pay or reload.<br/>
Portal: <a href="${login}" style="color:#2563eb;">${login}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:13px;color:#6b7280;line-height:1.5;">Questions? Just reply to this email.</p>
<p style="margin:8px 0 0;font-size:13px;color:#374151;font-weight:600;">— Verae</p>
<p style="margin:12px 0 0;font-size:11px;color:#9ca3af;">
<img src="${logo}" alt="" width="72" style="vertical-align:middle;max-width:72px;height:auto;border:0;opacity:0.85;"/>
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
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<void> {
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);
});
}

View file

@ -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<BillingApiDeps, 'customers' | 'usage' | 'invoices' | 'tiers'>;
}
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));

257
tests/billing-api.test.ts Normal file
View file

@ -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<string, string> = { 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']));
});
});