feat(billing): Usage & Billing API for the peergos-flutter app (+ commit live ns1 drift) #2
6 changed files with 1304 additions and 0 deletions
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.
commit
74e9183c5c
49
docs/USAGE-BILLING-API.md
Normal file
49
docs/USAGE-BILLING-API.md
Normal 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.
|
||||
30
src/app.ts
30
src/app.ts
|
|
@ -1,5 +1,13 @@
|
|||
import path from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import {
|
||||
BillingStripe,
|
||||
billingAdminRouter,
|
||||
InMemoryPurchaseOrderRepo,
|
||||
InMemoryStorageSampleRepo,
|
||||
PurchaseOrderRepo,
|
||||
StorageSampleRepo,
|
||||
} from './billing-api';
|
||||
import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
|
|
@ -241,6 +249,12 @@ export interface AppDeps {
|
|||
credits?: CreditLedgerLike;
|
||||
/** Long-lived xVerae- customer API tokens (LLM-key style). */
|
||||
apiTokens?: ApiTokenRepo;
|
||||
/** Usage & Billing API stores (additive tables on billing.db in production). */
|
||||
storageSamples?: StorageSampleRepo;
|
||||
purchaseOrders?: PurchaseOrderRepo;
|
||||
/** Stripe client for card management / pay-now; undefined → 503 on those routes. */
|
||||
billingStripe?: BillingStripe;
|
||||
stripePublishableKey?: string;
|
||||
}
|
||||
|
||||
const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml');
|
||||
|
|
@ -343,6 +357,14 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
}),
|
||||
);
|
||||
|
||||
const storageSamples = deps.storageSamples ?? new InMemoryStorageSampleRepo();
|
||||
const purchaseOrders = deps.purchaseOrders ?? new InMemoryPurchaseOrderRepo();
|
||||
const billingBase = {
|
||||
samples: storageSamples,
|
||||
purchaseOrders,
|
||||
stripe: deps.billingStripe,
|
||||
publishableKey: deps.stripePublishableKey,
|
||||
};
|
||||
app.use(
|
||||
'/admin/api',
|
||||
adminLoginRouter(adminUsers, deps.mailer),
|
||||
|
|
@ -355,6 +377,13 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
credits,
|
||||
deps.channelRates,
|
||||
),
|
||||
billingAdminRouter({
|
||||
...billingBase,
|
||||
customers,
|
||||
usage,
|
||||
invoices,
|
||||
tiers: () => pricingStore.getTiers(),
|
||||
}),
|
||||
);
|
||||
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));
|
||||
app.use('/client', express.static(path.join(PROJECT_ROOT, 'client')));
|
||||
|
|
@ -385,6 +414,7 @@ export function buildApp(deps: AppDeps = {}): {
|
|||
credits,
|
||||
apiTokens,
|
||||
mailer: deps.mailer,
|
||||
billing: billingBase,
|
||||
}),
|
||||
);
|
||||
app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal')));
|
||||
|
|
|
|||
936
src/billing-api.ts
Normal file
936
src/billing-api.ts
Normal 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;
|
||||
}
|
||||
15
src/index.ts
15
src/index.ts
|
|
@ -11,6 +11,11 @@ import { SqliteApiTokenRepo } from './db/api-token-repo';
|
|||
import { ensurePlatformOrg } from './peergos-billing';
|
||||
import { PROJECT_ROOT } from './paths';
|
||||
import { PaymentClient } from './portal';
|
||||
import {
|
||||
SqlitePurchaseOrderRepo,
|
||||
SqliteStorageSampleRepo,
|
||||
stripeBillingClient,
|
||||
} from './billing-api';
|
||||
|
||||
loadEnv({ path: path.join(PROJECT_ROOT, '.env') });
|
||||
|
||||
|
|
@ -51,6 +56,12 @@ async function main(): Promise<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';
|
||||
|
|
|
|||
|
|
@ -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
257
tests/billing-api.test.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue