feat(billing): Usage & Billing API for the peergos-flutter app (+ commit live ns1 drift) #2

Merged
marchon merged 2 commits from feat/usage-billing-api into overdue-negative-balance-only 2026-10-06 13:26:16 -04:00
6 changed files with 1304 additions and 0 deletions
Showing only changes of commit 74e9183c5c - Show all commits

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.
Grok Bot 2026-10-06 13:26:03 -04:00

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

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

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