From 2ebdabfa470ab375a6f3fd1883dc3c11737c8b7f Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:23:54 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20shareable=20reports=20+=20self-serv?= =?UTF-8?q?ice=20subscriptions=20(WP4)=20=E2=80=94=20share=20links=20route?= =?UTF-8?q?r,=200024=20migration,=20journal=20idx=2024?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0024_share_links_and_plan_signups.sql | 49 ++++ drizzle/meta/_journal.json | 7 + server/shareableReports.ts | 275 ++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 drizzle/0024_share_links_and_plan_signups.sql create mode 100644 server/shareableReports.ts diff --git a/drizzle/0024_share_links_and_plan_signups.sql b/drizzle/0024_share_links_and_plan_signups.sql new file mode 100644 index 0000000..cb9dad8 --- /dev/null +++ b/drizzle/0024_share_links_and_plan_signups.sql @@ -0,0 +1,49 @@ +-- 0024_share_links_and_plan_signups.sql +-- Shareable investigation reports (tokenised, expiring, redacted one-pagers) +-- and durable idempotency records for self-service plan signups. + +BEGIN; + +CREATE TABLE IF NOT EXISTS report_share_links ( + id uuid PRIMARY KEY, + tenant_id integer NOT NULL CHECK (tenant_id > 0), + investigation_ref text NOT NULL, + token_hash text NOT NULL, + created_by integer, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + view_count integer NOT NULL DEFAULT 0 CHECK (view_count >= 0), + last_viewed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS report_share_links_token_hash_idx + ON report_share_links (token_hash); +CREATE INDEX IF NOT EXISTS report_share_links_tenant_idx + ON report_share_links (tenant_id, created_at); +CREATE INDEX IF NOT EXISTS report_share_links_investigation_idx + ON report_share_links (tenant_id, investigation_ref); + +COMMENT ON TABLE report_share_links IS + 'Expiring share links for redacted investigation one-pagers; only the SHA-256 digest of the bis_sl_ token is stored, never the plaintext token.'; + +CREATE TABLE IF NOT EXISTS plan_signups ( + id uuid PRIMARY KEY, + tenant_id integer NOT NULL CHECK (tenant_id > 0), + plan_code text NOT NULL, + status text NOT NULL CHECK (status IN ('active', 'payment_failed', 'cancelled')), + billing_ref text, + idempotency_key text NOT NULL, + created_by integer, + created_at timestamptz NOT NULL DEFAULT now() +); +-- Idempotency keys are tenant-namespaced: per-tenant uniqueness prevents both +-- cross-tenant replay leaks and cross-tenant key squatting. +CREATE UNIQUE INDEX IF NOT EXISTS plan_signups_idempotency_key_unique + ON plan_signups (tenant_id, idempotency_key); +CREATE INDEX IF NOT EXISTS plan_signups_tenant_idx + ON plan_signups (tenant_id, created_at); + +COMMENT ON TABLE plan_signups IS + 'Durable, tenant-scoped idempotency records for self-service plan signups; a replayed idempotency key returns the original result and never re-settles payment.'; + +COMMIT; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4032628..a48cbef 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1788634800000, "tag": "0023_subject_portal", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1788638400000, + "tag": "0024_share_links_and_plan_signups", + "breakpoints": true } ] } diff --git a/server/shareableReports.ts b/server/shareableReports.ts new file mode 100644 index 0000000..5e6c7fd --- /dev/null +++ b/server/shareableReports.ts @@ -0,0 +1,275 @@ +/** + * server/shareableReports.ts + * ───────────────────────────────────────────────────────────────────────────── + * Shareable investigation reports (Intelius-style instant-report analog). + * + * An operator creates a time-boxed share link for an investigation that belongs + * to their tenant. The link token (`bis_sl_`) is shown exactly once; + * only its SHA-256 hex digest is persisted (same scheme as server/apiTokens.ts + * and the OpenClaw bearer validation in server/openclawEndpoints.ts). + * + * Token holders receive a REDACTED one-pager: subject name, investigation ref, + * an overall risk BAND (never a raw component score), per-source screening + * outcomes reduced to pass/consider/fail, the field-visit outcome, a thin-file + * flag, generation time, and the tenant display name. Referee identities, raw + * sanctions payloads, internal notes, and user identifiers are never selected + * and never serialised. + */ + +import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure, publicProcedure, router, writeProcedure } from "./_core/trpc"; +import { getPgPool } from "./db"; +import { ENV } from "./_core/env"; + +const TOKEN_PREFIX = "bis_sl_"; +const DEFAULT_EXPIRY_DAYS = 7; +const MAX_EXPIRY_DAYS = 30; + +type Queryable = { query: (text: string, values?: unknown[]) => Promise<{ rows: any[]; rowCount?: number | null }> }; + +function requireTenant(ctx: { tenantId: number | null; user: { id: number } | null }) { + if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: "An authenticated operator is required" }); + if (!ctx.tenantId || ctx.tenantId <= 0) throw new TRPCError({ code: "FORBIDDEN", message: "An explicit tenant context is required" }); + return { tenantId: ctx.tenantId, userId: ctx.user.id }; +} + +async function poolOrFail(): Promise { + const pool = await getPgPool(); + if (!pool) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Share-link storage is unavailable" }); + return pool; +} + +function hashShareToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +async function writeAuditLog(client: Queryable, entry: { + tenantId: number; userId: number; action: string; targetRef?: string; result?: "success" | "warning" | "failure"; detail?: unknown; +}) { + const createdAt = new Date(); + const result = entry.result ?? "success"; + const payload = [String(entry.userId), "report", entry.action, entry.targetRef ?? "", result, createdAt.toISOString()].join("|"); + const integrityHash = createHmac("sha256", ENV.auditHmacSecret).update(payload).digest("hex").slice(0, 64); + await client.query( + `INSERT INTO audit_log ("tenantId", "userId", category, action, "targetRef", result, detail, "integrityHash", "createdAt") + VALUES ($1, $2, 'report', $3, $4, $5, $6::jsonb, $7, $8)`, + [entry.tenantId, entry.userId, entry.action, entry.targetRef ?? null, result, JSON.stringify(entry.detail ?? {}), integrityHash, createdAt], + ).catch(() => undefined); +} + +async function publishEvent(eventType: string, subjectRef: string, severity: string, payload: unknown) { + try { + await fetch(`${ENV.eventProcessorUrl}/v1/events`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-BIS-Key": ENV.bisGatewayKey }, + body: JSON.stringify({ event_type: eventType, subject_id: subjectRef, subject_ref: subjectRef, severity, payload, source_service: "bis-bff" }), + }); + } catch (e) { + console.warn("[EventProcessor] Failed to publish event:", e); + } +} + +/** Map an assessment_outcome enum value to a public pass/consider/fail band. */ +function outcomeBand(outcome: string | null): "pass" | "consider" | "fail" { + switch (outcome) { + case "clear": return "pass"; + case "adverse": + case "suspended_licence": + case "revoked_licence": return "fail"; + default: return "consider"; // consider / pending / unverified / null + } +} + +/** Reduce an overall risk signal to a band; raw scores never leave this function. */ +function riskBand(riskTier: string | null, riskScore: number | null): "low" | "medium" | "high" | "critical" | "unrated" { + if (riskTier === "low" || riskTier === "medium" || riskTier === "high" || riskTier === "critical") return riskTier; + if (riskScore === null || !Number.isFinite(riskScore)) return "unrated"; + if (riskScore < 25) return "low"; + if (riskScore < 50) return "medium"; + if (riskScore < 75) return "high"; + return "critical"; +} + +export const shareableReportsRouter = router({ + createShareLink: writeProcedure + .input(z.object({ + investigationRef: z.string().min(4).max(64), + expiresInDays: z.number().int().min(1).max(MAX_EXPIRY_DAYS).default(DEFAULT_EXPIRY_DAYS), + })) + .mutation(async ({ input, ctx }) => { + const { tenantId, userId } = requireTenant(ctx); + const pool = await poolOrFail(); + const client = await (pool as any).connect(); + try { + await client.query("BEGIN"); + const investigation = await client.query( + `SELECT id FROM investigations WHERE ref = $1 AND "tenantId" = $2 AND "deletedAt" IS NULL FOR SHARE`, + [input.investigationRef, tenantId], + ); + if (investigation.rowCount !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "Investigation was not found in this tenant" }); + } + const token = `${TOKEN_PREFIX}${randomBytes(24).toString("base64url")}`; + const id = randomUUID(); + const expiresAt = new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000); + const inserted = await client.query( + `INSERT INTO report_share_links (id, tenant_id, investigation_ref, token_hash, created_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, expires_at, created_at`, + [id, tenantId, input.investigationRef, hashShareToken(token), userId, expiresAt], + ); + await writeAuditLog(client, { + tenantId, userId, action: "Report share link created", targetRef: input.investigationRef, + detail: { shareLinkId: id, expiresAt: expiresAt.toISOString() }, + }); + await client.query("COMMIT"); + await publishEvent("REPORT_SHARE_CREATED", input.investigationRef, "info", { shareLinkId: id, expiresAt: expiresAt.toISOString(), tenantId }); + // The plaintext token is returned exactly once and is never persisted. + return { + shareLinkId: inserted.rows[0].id, + token, + investigationRef: input.investigationRef, + expiresAt: new Date(inserted.rows[0].expires_at).toISOString(), + }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + }), + + getSharedReport: publicProcedure + .input(z.object({ token: z.string().regex(/^bis_sl_[A-Za-z0-9_-]{32}$/) })) + .query(async ({ input }) => { + const pool = await poolOrFail(); + // Atomic validity check + view accounting in a single statement: an + // expired or revoked link cannot be raced into an extra view. + const claimed = await pool.query( + `UPDATE report_share_links + SET view_count = view_count + 1, last_viewed_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now() + RETURNING id, tenant_id, investigation_ref`, + [hashShareToken(input.token)], + ); + if ((claimed.rowCount ?? 0) !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "This shared report link is invalid, expired, or revoked" }); + } + const link = claimed.rows[0] as { id: string; tenant_id: number; investigation_ref: string }; + + const investigation = await pool.query( + `SELECT id, "subjectName", ref, status, "riskTier", "riskScore", "completedAt" + FROM investigations + WHERE ref = $1 AND "tenantId" = $2 AND "deletedAt" IS NULL`, + [link.investigation_ref, link.tenant_id], + ); + if ((investigation.rowCount ?? 0) !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "The investigation backing this shared report is unavailable" }); + } + const inv = investigation.rows[0] as { + id: number; subjectName: string; ref: string; status: string; + riskTier: string | null; riskScore: number | null; completedAt: Date | null; + }; + + const screening = await pool.query( + `SELECT sr."screeningType" AS source, sr.outcome + FROM screening_results sr + JOIN screening_orders so ON so.id = sr."orderId" + WHERE so."investigationRef" = $1 AND so."tenantId" = $2 AND so."deletedAt" IS NULL + AND sr.status = 'completed' + ORDER BY sr."screeningType" ASC`, + [link.investigation_ref, link.tenant_id], + ); + const fieldVisit = await pool.query( + `SELECT outcome, "subjectPresent", "addressConfirmed", "submittedAt" + FROM field_visit_reports + WHERE "investigationId" = $1 + ORDER BY "createdAt" DESC + LIMIT 1`, + [inv.id], + ); + const tenant = await pool.query(`SELECT name FROM tenants WHERE id = $1`, [link.tenant_id]); + + const screeningSummary = (screening.rows as Array<{ source: string; outcome: string | null }>) + .map((row) => ({ source: row.source, outcome: outcomeBand(row.outcome) })); + const visit = fieldVisit.rows[0] as { outcome: string | null; subjectPresent: boolean | null; addressConfirmed: boolean | null; submittedAt: Date | null } | undefined; + + // Whitelisted redacted one-pager. No referee identities, no raw provider + // payloads, no internal notes, and no user identifiers are ever selected. + return { + subjectName: inv.subjectName, + investigationRef: inv.ref, + riskBand: riskBand(inv.riskTier, inv.riskScore === null ? null : Number(inv.riskScore)), + screening: screeningSummary, + fieldVisit: visit + ? { + outcome: visit.outcome === "confirmed" || visit.outcome === "unconfirmed" || visit.outcome === "inconclusive" ? visit.outcome : "inconclusive", + conductedAt: visit.submittedAt ? new Date(visit.submittedAt).toISOString() : null, + } + : null, + thinFile: inv.status === "thin_file" || screeningSummary.length === 0, + generatedAt: new Date().toISOString(), + completedAt: inv.completedAt ? new Date(inv.completedAt).toISOString() : null, + tenantName: tenant.rows[0] ? String(tenant.rows[0].name) : "BIS tenant", + }; + }), + + revokeShareLink: writeProcedure + .input(z.object({ shareLinkId: z.string().uuid() })) + .mutation(async ({ input, ctx }) => { + const { tenantId, userId } = requireTenant(ctx); + const pool = await poolOrFail(); + const revoked = await pool.query( + `UPDATE report_share_links SET revoked_at = now() + WHERE id = $1 AND tenant_id = $2 AND revoked_at IS NULL + RETURNING id, investigation_ref`, + [input.shareLinkId, tenantId], + ); + if ((revoked.rowCount ?? 0) !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "Share link was not found in this tenant or is already revoked" }); + } + await writeAuditLog(pool, { + tenantId, userId, action: "Report share link revoked", targetRef: revoked.rows[0].investigation_ref, + detail: { shareLinkId: input.shareLinkId }, + }); + await publishEvent("REPORT_SHARE_REVOKED", revoked.rows[0].investigation_ref, "info", { shareLinkId: input.shareLinkId, tenantId }); + return { shareLinkId: input.shareLinkId, revoked: true as const }; + }), + + listShareLinks: protectedProcedure + .input(z.object({ investigationRef: z.string().min(4).max(64).optional() })) + .query(async ({ input, ctx }) => { + const { tenantId } = requireTenant(ctx); + const pool = await poolOrFail(); + const result = input.investigationRef + ? await pool.query( + `SELECT id, investigation_ref, expires_at, revoked_at, view_count, last_viewed_at, created_at + FROM report_share_links + WHERE tenant_id = $1 AND investigation_ref = $2 + ORDER BY created_at DESC LIMIT 100`, + [tenantId, input.investigationRef], + ) + : await pool.query( + `SELECT id, investigation_ref, expires_at, revoked_at, view_count, last_viewed_at, created_at + FROM report_share_links + WHERE tenant_id = $1 + ORDER BY created_at DESC LIMIT 100`, + [tenantId], + ); + // token_hash is deliberately never selected here. + return result.rows.map((row: any) => ({ + shareLinkId: row.id as string, + investigationRef: row.investigation_ref as string, + expiresAt: new Date(row.expires_at).toISOString(), + revokedAt: row.revoked_at ? new Date(row.revoked_at).toISOString() : null, + viewCount: Number(row.view_count), + lastViewedAt: row.last_viewed_at ? new Date(row.last_viewed_at).toISOString() : null, + createdAt: new Date(row.created_at).toISOString(), + active: row.revoked_at === null && new Date(row.expires_at).getTime() > Date.now(), + })); + }), +}); + +export const __shareableReportsInternals = { hashShareToken, outcomeBand, riskBand, TOKEN_PREFIX }; From 8912da7f66eae5ce31b52f055c9870464f8fce08 Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:30:59 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20self-service=20billing=20router=20(?= =?UTF-8?q?WP4)=20=E2=80=94=20tenant-scoped=20idempotent=20signup=20via=20?= =?UTF-8?q?existing=20settlement=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/selfServiceBilling.ts | 373 +++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 server/selfServiceBilling.ts diff --git a/server/selfServiceBilling.ts b/server/selfServiceBilling.ts new file mode 100644 index 0000000..b76afdb --- /dev/null +++ b/server/selfServiceBilling.ts @@ -0,0 +1,373 @@ +/** + * server/selfServiceBilling.ts + * ───────────────────────────────────────────────────────────────────────────── + * Self-service subscription signup (self-serve pricing-page analog). + * + * Money NEVER moves here. A paid signup reuses the existing hardened billing + * internals: + * - the plan catalogue comes from `billing_plans` (server/billingCommercial.ts), + * - payment is settled exclusively through `settlePaystackPayment` + * (server/billingSettlement.ts), which re-verifies the provider transaction + * against a server-created `billing_payment_intents` row and posts the + * deterministic TigerBeetle transfer before any state changes, + * - the subscription + included-check entitlement are written into the + * existing `tenant_subscriptions` / `billing_entitlements` tables. + * + * Fail-closed: any payment or ledger failure raises a typed TRPCError and NO + * subscription or entitlement is created; the attempt is durably recorded as a + * plan_signups row with status 'payment_failed' so replays of the same + * idempotency key return that original failure result. + * + * Idempotent: the client-supplied idempotency key is backed by a per-tenant + * UNIQUE constraint on plan_signups.(tenant_id, idempotency_key); a replay + * returns the original result without re-settling payment. Keys are + * tenant-namespaced — one tenant's key is never visible to another tenant and + * is treated as a new key there, so replays can never leak a foreign signup + * or billing reference. + */ + +import { createHmac, randomUUID } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure, publicProcedure, router, writeProcedure } from "./_core/trpc"; +import { getPgPool } from "./db"; +import { ENV } from "./_core/env"; +import { settlePaystackPayment } from "./billingSettlement"; + +type Queryable = { query: (text: string, values?: unknown[]) => Promise<{ rows: any[]; rowCount?: number | null }> }; + +const PLAN_CODE = /^[a-z][a-z0-9_]{2,63}$/; +const PAYMENT_REFERENCE = /^BIS-TOP-[A-Z0-9]{24}$/; + +function requireTenant(ctx: { tenantId: number | null; user: { id: number } | null }) { + if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: "An authenticated operator is required" }); + if (!ctx.tenantId || ctx.tenantId <= 0) throw new TRPCError({ code: "FORBIDDEN", message: "An explicit tenant context is required" }); + return { tenantId: ctx.tenantId, userId: ctx.user.id }; +} + +async function poolOrFail(): Promise { + const pool = await getPgPool(); + if (!pool) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Self-service billing storage is unavailable" }); + return pool; +} + +async function writeAuditLog(client: Queryable, entry: { + tenantId: number; userId: number; action: string; targetRef?: string; result?: "success" | "warning" | "failure"; detail?: unknown; +}) { + const createdAt = new Date(); + const result = entry.result ?? "success"; + const payload = [String(entry.userId), "system", entry.action, entry.targetRef ?? "", result, createdAt.toISOString()].join("|"); + const integrityHash = createHmac("sha256", ENV.auditHmacSecret).update(payload).digest("hex").slice(0, 64); + await client.query( + `INSERT INTO audit_log ("tenantId", "userId", category, action, "targetRef", result, detail, "integrityHash", "createdAt") + VALUES ($1, $2, 'system', $3, $4, $5, $6::jsonb, $7, $8)`, + [entry.tenantId, entry.userId, entry.action, entry.targetRef ?? null, result, JSON.stringify(entry.detail ?? {}), integrityHash, createdAt], + ).catch(() => undefined); +} + +async function publishEvent(eventType: string, subjectRef: string, severity: string, payload: unknown) { + try { + await fetch(`${ENV.eventProcessorUrl}/v1/events`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-BIS-Key": ENV.bisGatewayKey }, + body: JSON.stringify({ event_type: eventType, subject_id: subjectRef, subject_ref: subjectRef, severity, payload, source_service: "bis-bff" }), + }); + } catch (e) { + console.warn("[EventProcessor] Failed to publish event:", e); + } +} + +function periodEndFor(interval: "monthly" | "annual", start: Date): Date { + const end = new Date(start); + if (interval === "annual") end.setFullYear(end.getFullYear() + 1); + else end.setMonth(end.getMonth() + 1); + return end; +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as { code?: string }).code === "23505"; +} + +export const selfServiceBillingRouter = router({ + /** + * Public plan catalogue. Read-only projection of billing_plans; contains no + * tenant data, so it is safe to expose without tenant context. + */ + listPublicPlans: publicProcedure.query(async () => { + const pool = await poolOrFail(); + const { rows } = await pool.query( + `SELECT plan_code, display_name, billing_interval, price_kobo, included_completed_checks, overage_price_kobo, version + FROM billing_plans WHERE active = true ORDER BY price_kobo ASC, plan_code ASC`, + ); + return rows.map((row) => ({ + planCode: row.plan_code as string, + displayName: row.display_name as string, + billingInterval: row.billing_interval as "monthly" | "annual", + priceKobo: Number(row.price_kobo), + priceNGN: Number(row.price_kobo) / 100, + includedCompletedChecks: Number(row.included_completed_checks), + overagePriceKobo: Number(row.overage_price_kobo), + currency: "NGN" as const, + version: Number(row.version), + })); + }), + + signup: writeProcedure + .input(z.object({ + planCode: z.string().regex(PLAN_CODE), + idempotencyKey: z.string().min(8).max(128), + paymentReference: z.string().regex(PAYMENT_REFERENCE).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { tenantId, userId } = requireTenant(ctx); + const pool = await poolOrFail(); + + // Idempotency replay, tenant-scoped: the (tenant_id, idempotency_key) + // unique pair is the durable record of the first attempt; a replay + // returns that original result without touching money. A key created by + // ANOTHER tenant is not visible here — it behaves as a brand-new key for + // this tenant and can never leak the other tenant's signup or billing + // reference. + const prior = await pool.query( + `SELECT id, plan_code, status, billing_ref FROM plan_signups + WHERE tenant_id = $1 AND idempotency_key = $2`, + [tenantId, input.idempotencyKey], + ); + if ((prior.rowCount ?? 0) === 1) { + const row = prior.rows[0]; + let subscriptionId: string | null = null; + if (row.status === "active" && row.billing_ref) { + const sub = await pool.query( + `SELECT id FROM tenant_subscriptions WHERE tenant_id = $1 AND provider_subscription_ref = $2 LIMIT 1`, + [tenantId, row.billing_ref], + ); + subscriptionId = sub.rows[0]?.id ?? null; + } + return { + signupId: row.id as string, + subscriptionId, + planCode: row.plan_code as string, + status: row.status as string, + billingRef: (row.billing_ref ?? null) as string | null, + idempotent: true as const, + }; + } + + // Resolve and validate the plan from the existing commercial catalogue. + const planResult = await pool.query( + `SELECT id, plan_code, billing_interval, price_kobo, included_completed_checks + FROM billing_plans WHERE plan_code = $1 AND active = true`, + [input.planCode], + ); + if ((planResult.rowCount ?? 0) !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "The requested active plan does not exist" }); + } + const plan = planResult.rows[0] as { + id: string; plan_code: string; billing_interval: "monthly" | "annual"; + price_kobo: string | number; included_completed_checks: string | number; + }; + const priceKobo = Number(plan.price_kobo); + const includedChecks = Number(plan.included_completed_checks); + + // Paid plans settle through the existing hardened payment path only. + let billingRef: string | null = null; + if (priceKobo > 0) { + if (!input.paymentReference) { + throw new TRPCError({ code: "BAD_REQUEST", message: "A server-created subscription payment reference is required for a paid plan" }); + } + // Bind the payment to this tenant and purpose before settling; the + // intent row is server-created (payment_intent outbox hardening). + const intent = await pool.query( + `SELECT tenant_id, purpose, amount_kobo FROM billing_payment_intents + WHERE provider = 'paystack' AND provider_reference = $1`, + [input.paymentReference], + ); + if ((intent.rowCount ?? 0) !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "No server-created payment intent exists for this reference" }); + } + const intentRow = intent.rows[0]; + if (Number(intentRow.tenant_id) !== tenantId || intentRow.purpose !== "subscription_invoice") { + throw new TRPCError({ code: "FORBIDDEN", message: "The payment intent is not bound to this tenant subscription" }); + } + if (Number(intentRow.amount_kobo) !== priceKobo) { + throw new TRPCError({ code: "CONFLICT", message: "The payment intent amount does not match the plan price" }); + } + + try { + // Existing billing internals: provider re-verification + deterministic + // TigerBeetle transfer. Any failure throws and nothing is activated. + await settlePaystackPayment(input.paymentReference); + } catch (error) { + // Durable failed-attempt record: a replay of this idempotency key + // returns this original failure result instead of re-settling. The + // row is best-effort; a concurrent attempt wins on the UNIQUE key. + await pool.query( + `INSERT INTO plan_signups (id, tenant_id, plan_code, status, billing_ref, idempotency_key, created_by) + VALUES ($1, $2, $3, 'payment_failed', $4, $5, $6) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING`, + [randomUUID(), tenantId, input.planCode, input.paymentReference, input.idempotencyKey, userId], + ).catch(() => undefined); + await writeAuditLog(pool, { + tenantId, userId, action: `Self-service signup payment failed for plan ${input.planCode}`, + targetRef: input.paymentReference, result: "failure", + detail: { idempotencyKey: input.idempotencyKey, error: error instanceof Error ? error.message : String(error) }, + }); + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Subscription payment could not be settled; no subscription was activated" }); + } + billingRef = input.paymentReference; + } + + // Activation is transactional: cancel any current subscription, create the + // new one, grant the included-check entitlement, and record the durable + // idempotency row atomically. A concurrent replay loses on the UNIQUE + // idempotency key and is re-read as the original result. + const client = await (pool as any).connect(); + const signupId = randomUUID(); + const subscriptionId = randomUUID(); + const periodStart = new Date(); + const periodEnd = periodEndFor(plan.billing_interval, periodStart); + // tenant_subscriptions enforces a GLOBAL UNIQUE(provider, + // provider_subscription_ref) and billing_entitlements a global + // UNIQUE(source_reference): both synthetic references are therefore + // tenant-namespaced so the same idempotency key in another tenant can + // never collide (key squatting) or 500 on a duplicate key. + const providerRef = billingRef ?? `self-serve-free:${tenantId}:${input.idempotencyKey}`; + try { + await client.query("BEGIN"); + await client.query( + `UPDATE tenant_subscriptions SET status = 'cancelled', cancelled_at = now(), updated_at = now() + WHERE tenant_id = $1 AND status IN ('pending', 'active', 'past_due', 'cancelling')`, + [tenantId], + ); + await client.query( + `INSERT INTO tenant_subscriptions + (id, tenant_id, plan_id, provider, provider_subscription_ref, status, current_period_start, current_period_end, created_by) + VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)`, + [subscriptionId, tenantId, plan.id, priceKobo > 0 ? "paystack" : "manual_contract", providerRef, periodStart, periodEnd, userId], + ); + if (includedChecks > 0) { + await client.query( + `INSERT INTO billing_entitlements + (id, tenant_id, subscription_id, entitlement_kind, total_units, period_start, period_end, status, source_reference) + VALUES ($1, $2, $3, 'subscription_included_check', $4, $5, $6, 'active', $7)`, + [randomUUID(), tenantId, subscriptionId, includedChecks, periodStart, periodEnd, `self-serve-signup:${tenantId}:${input.idempotencyKey}`], + ); + } + await client.query( + `INSERT INTO plan_signups (id, tenant_id, plan_code, status, billing_ref, idempotency_key, created_by) + VALUES ($1, $2, $3, 'active', $4, $5, $6)`, + [signupId, tenantId, input.planCode, providerRef, input.idempotencyKey, userId], + ); + await writeAuditLog(client, { + tenantId, userId, action: `Self-service plan signup activated: ${input.planCode}`, + targetRef: providerRef, + detail: { signupId, subscriptionId, idempotencyKey: input.idempotencyKey, priceKobo }, + }); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + if (isUniqueViolation(error)) { + // Lost the same-tenant idempotency race: re-read THIS tenant's row + // and return the original committed result. + const winner = await pool.query( + `SELECT id, plan_code, status, billing_ref FROM plan_signups + WHERE tenant_id = $1 AND idempotency_key = $2`, + [tenantId, input.idempotencyKey], + ); + if ((winner.rowCount ?? 0) === 1) { + const row = winner.rows[0]; + return { + signupId: row.id as string, + subscriptionId: null, + planCode: row.plan_code as string, + status: row.status as string, + billingRef: (row.billing_ref ?? null) as string | null, + idempotent: true as const, + }; + } + } + throw error; + } finally { + client.release(); + } + + await publishEvent("PLAN_SIGNUP_ACTIVATED", String(tenantId), "info", { + signupId, subscriptionId, planCode: input.planCode, tenantId, priceKobo, billingRef: providerRef, + }); + return { + signupId, + subscriptionId, + planCode: input.planCode, + status: "active" as const, + billingRef: providerRef, + idempotent: false as const, + }; + }), + + mySubscription: protectedProcedure.query(async ({ ctx }) => { + const { tenantId } = requireTenant(ctx); + const pool = await poolOrFail(); + const { rows } = await pool.query( + `SELECT s.id, s.status, s.current_period_start, s.current_period_end, s.cancel_at_period_end, + p.plan_code, p.display_name, p.billing_interval, p.price_kobo, p.included_completed_checks + FROM tenant_subscriptions s + JOIN billing_plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 AND s.status IN ('pending', 'active', 'past_due', 'cancelling') + ORDER BY s.created_at DESC + LIMIT 1`, + [tenantId], + ); + if (rows.length !== 1) return { subscription: null }; + const row = rows[0]; + return { + subscription: { + subscriptionId: row.id as string, + status: row.status as string, + planCode: row.plan_code as string, + displayName: row.display_name as string, + billingInterval: row.billing_interval as string, + priceKobo: Number(row.price_kobo), + includedCompletedChecks: Number(row.included_completed_checks), + currentPeriodStart: new Date(row.current_period_start).toISOString(), + currentPeriodEnd: new Date(row.current_period_end).toISOString(), + cancelAtPeriodEnd: Boolean(row.cancel_at_period_end), + currency: "NGN" as const, + }, + }; + }), + + usageSummary: protectedProcedure.query(async ({ ctx }) => { + const { tenantId } = requireTenant(ctx); + const pool = await poolOrFail(); + const entitlements = await pool.query( + `SELECT COALESCE(SUM(total_units), 0) AS total_units, + COALESCE(SUM(consumed_units), 0) AS consumed_units, + COALESCE(SUM(reserved_units), 0) AS reserved_units + FROM billing_entitlements + WHERE tenant_id = $1 AND status = 'active' AND period_start <= now() AND period_end > now()`, + [tenantId], + ); + const recent = await pool.query( + `SELECT COUNT(*)::int AS completed_checks + FROM billing_usage_events + WHERE tenant_id = $1 AND usage_type = 'completed_authorized_check' + AND occurred_at > now() - interval '30 days'`, + [tenantId], + ); + const totals = entitlements.rows[0] ?? { total_units: 0, consumed_units: 0, reserved_units: 0 }; + const total = Number(totals.total_units); + const consumed = Number(totals.consumed_units); + const reserved = Number(totals.reserved_units); + return { + tenantId, + includedChecks: total, + consumedChecks: consumed, + reservedChecks: reserved, + remainingChecks: Math.max(0, total - consumed - reserved), + completedChecksLast30Days: Number(recent.rows[0]?.completed_checks ?? 0), + }; + }), +}); + +export const __selfServiceBillingInternals = { periodEndFor, isUniqueViolation }; From b48e539bd7170108cc2750d8955ed82c077c9b4a Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:34:09 -0400 Subject: [PATCH 3/3] =?UTF-8?q?test:=20WP4=20share/subscribe=20suite=20?= =?UTF-8?q?=E2=80=94=20lifecycle,=20redaction,=20idempotency=20(incl.=20cr?= =?UTF-8?q?oss-tenant=20+=20free-plan),=20fail-closed,=20tenant=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/share-subscribe.test.ts | 841 +++++++++++++++++++++++++++++++++ 1 file changed, 841 insertions(+) create mode 100644 server/share-subscribe.test.ts diff --git a/server/share-subscribe.test.ts b/server/share-subscribe.test.ts new file mode 100644 index 0000000..ad0c78f --- /dev/null +++ b/server/share-subscribe.test.ts @@ -0,0 +1,841 @@ +/** + * server/share-subscribe.test.ts + * ───────────────────────────────────────────────────────────────────────────── + * WP4 coverage for shareable investigation reports + self-service signups: + * + * - share-token lifecycle: create → view (atomic count) → revoke → rejected + * - redaction shape: forbidden keys (referees, raw payloads, notes, user IDs, + * raw scores, token hashes) are absent from the serialised one-pager + * - idempotency replay: a repeated signup idempotency key returns the + * original result and never re-settles payment + * - fail-closed signup: a failed provider payment activates NO subscription + * - tenant scoping: cross-tenant share creation / payment binding is denied + * + * The pg pool is replaced by a stateful in-memory handler that executes the + * real SQL strings issued by the routers and by settlePaystackPayment. External + * HTTP boundaries (Paystack verify, TigerBeetle ledger, event processor) are + * intercepted with a stubbed global fetch; all business logic under test is + * the production code path. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + process.env.JWT_SECRET = "share-subscribe-test-secret"; + process.env.TIGERBEETLE_URL = "http://tigerbeetle.test"; + process.env.PAYSTACK_SECRET_KEY = "sk_test_share_subscribe"; + process.env.EVENT_PROCESSOR_URL = "http://events.test"; +}); + +// ─── Stateful in-memory PostgreSQL ──────────────────────────────────────────── + +type Row = Record; + +type State = ReturnType; + +function makeState() { + return { + tenants: new Map([[1, "Acme Verification Ltd"], [2, "Other Tenant Co"]]), + investigations: new Map(), + shareLinks: [] as Row[], + screening: [] as Row[], + fieldVisits: [] as Row[], + plans: new Map(), + intents: new Map(), + topups: [] as Row[], + signups: [] as Row[], + subscriptions: [] as Row[], + entitlements: [] as Row[], + usageEvents: [] as Row[], + auditLog: [] as Row[], + }; +} + +function rows(list: Row[]) { + return { rows: list, rowCount: list.length }; +} + +function fakeQuery(state: State) { + return async (text: string, values: unknown[] = []): Promise<{ rows: Row[]; rowCount: number }> => { + const sql = text.replace(/\s+/g, " ").trim(); + const upper = sql.toUpperCase(); + if (upper === "BEGIN" || upper === "COMMIT" || upper === "ROLLBACK") return rows([]); + + // ── investigations ────────────────────────────────────────────────────── + if (sql.startsWith("SELECT id FROM investigations")) { + const inv = state.investigations.get(String(values[0])); + return rows(inv && inv.tenantId === values[1] && !inv.deletedAt ? [{ id: inv.id }] : []); + } + if (sql.startsWith('SELECT id, "subjectName"')) { + const inv = state.investigations.get(String(values[0])); + return rows(inv && inv.tenantId === values[1] && !inv.deletedAt ? [inv] : []); + } + + // ── report_share_links ────────────────────────────────────────────────── + if (sql.startsWith("INSERT INTO report_share_links")) { + const [id, tenantId, investigationRef, tokenHash, createdBy, expiresAt] = values as any[]; + if (state.shareLinks.some((l) => l.token_hash === tokenHash)) { + const err = new Error("duplicate key value violates unique constraint") as any; + err.code = "23505"; + throw err; + } + const row = { id, tenant_id: tenantId, investigation_ref: investigationRef, token_hash: tokenHash, created_by: createdBy, expires_at: expiresAt, revoked_at: null, view_count: 0, last_viewed_at: null, created_at: new Date() }; + state.shareLinks.push(row); + return rows([{ id: row.id, expires_at: row.expires_at, created_at: row.created_at }]); + } + if (sql.startsWith("UPDATE report_share_links SET view_count")) { + const link = state.shareLinks.find((l) => l.token_hash === values[0] && l.revoked_at === null && new Date(l.expires_at).getTime() > Date.now()); + if (!link) return rows([]); + link.view_count += 1; + link.last_viewed_at = new Date(); + return rows([{ id: link.id, tenant_id: link.tenant_id, investigation_ref: link.investigation_ref }]); + } + if (sql.startsWith("UPDATE report_share_links SET revoked_at")) { + const link = state.shareLinks.find((l) => l.id === values[0] && l.tenant_id === values[1] && l.revoked_at === null); + if (!link) return rows([]); + link.revoked_at = new Date(); + return rows([{ id: link.id, investigation_ref: link.investigation_ref }]); + } + if (sql.startsWith("SELECT id, investigation_ref, expires_at")) { + const list = state.shareLinks + .filter((l) => l.tenant_id === values[0] && (values.length < 2 || l.investigation_ref === values[1])) + .sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); + return rows(list); + } + + // ── screening / field visits / tenants ────────────────────────────────── + if (sql.includes("FROM screening_results")) { + const list = state.screening + .filter((s) => s.investigationRef === values[0] && s.tenantId === values[1] && s.status === "completed") + .sort((a, b) => String(a.screeningType).localeCompare(String(b.screeningType))); + return rows(list.map((s) => ({ source: s.screeningType, outcome: s.outcome }))); + } + if (sql.includes("FROM field_visit_reports")) { + const list = state.fieldVisits.filter((v) => v.investigationId === values[0]); + return rows(list.slice(0, 1)); + } + if (sql.startsWith("SELECT name FROM tenants")) { + const name = state.tenants.get(Number(values[0])); + return rows(name ? [{ name }] : []); + } + + // ── audit ─────────────────────────────────────────────────────────────── + if (sql.startsWith("INSERT INTO audit_log")) { + state.auditLog.push({ tenantId: values[0], userId: values[1], action: values[2], targetRef: values[3], result: values[4] }); + return rows([]); + } + + // ── plan_signups ──────────────────────────────────────────────────────── + if (sql.startsWith("SELECT id, plan_code, status, billing_ref FROM plan_signups")) { + // Tenant-scoped replay lookup: WHERE tenant_id = $1 AND idempotency_key = $2 + return rows(state.signups.filter((s) => s.tenant_id === values[0] && s.idempotency_key === values[1])); + } + if (sql.startsWith("INSERT INTO plan_signups")) { + const [id, tenantId, planCode, billingRef, idempotencyKey, createdBy] = values as any[]; + // UNIQUE (tenant_id, idempotency_key) + if (state.signups.some((s) => s.tenant_id === tenantId && s.idempotency_key === idempotencyKey)) { + if (sql.includes("ON CONFLICT")) return rows([]); // ON CONFLICT DO NOTHING + const err = new Error("duplicate key value violates unique constraint") as any; + err.code = "23505"; + throw err; + } + const status = sql.includes("'payment_failed'") ? "payment_failed" : "active"; + state.signups.push({ id, tenant_id: tenantId, plan_code: planCode, status, billing_ref: billingRef, idempotency_key: idempotencyKey, created_by: createdBy, created_at: new Date() }); + return rows([]); + } + + // ── billing_plans ─────────────────────────────────────────────────────── + if (sql.startsWith("SELECT id, plan_code, billing_interval, price_kobo")) { + const plan = state.plans.get(String(values[0])); + return rows(plan && plan.active ? [plan] : []); + } + if (sql.startsWith("SELECT plan_code, display_name")) { + return rows([...state.plans.values()].filter((p) => p.active).sort((a, b) => Number(a.price_kobo) - Number(b.price_kobo))); + } + + // ── billing_payment_intents ───────────────────────────────────────────── + if (sql.includes("FROM billing_payment_intents") && sql.includes("FOR UPDATE")) { + const intent = state.intents.get(String(values[0])); + return rows(intent ? [intent] : []); + } + if (sql.startsWith("SELECT tenant_id, purpose, amount_kobo FROM billing_payment_intents")) { + const intent = state.intents.get(String(values[0])); + return rows(intent ? [intent] : []); + } + if (sql.startsWith("UPDATE billing_payment_intents SET status = 'credited'")) { + const intent = [...state.intents.values()].find((i) => i.id === values[0]); + if (!intent || !["pending", "verified"].includes(intent.status)) return rows([]); + intent.status = "credited"; + return rows([{ id: intent.id }]); + } + + // ── billing_topups ────────────────────────────────────────────────────── + if (sql.startsWith("INSERT INTO billing_topups")) { + state.topups.push({ tenantId: values[0], reference: values[1], amountKobo: values[2], channel: values[3], tbTransferId: values[4] }); + return rows([]); + } + + // ── tenant_subscriptions ──────────────────────────────────────────────── + if (sql.startsWith("SELECT id FROM tenant_subscriptions WHERE tenant_id")) { + return rows(state.subscriptions.filter((s) => s.tenant_id === values[0] && s.provider_subscription_ref === values[1]).map((s) => ({ id: s.id }))); + } + if (sql.startsWith("UPDATE tenant_subscriptions SET status = 'cancelled'")) { + let count = 0; + for (const s of state.subscriptions) { + if (s.tenant_id === values[0] && ["pending", "active", "past_due", "cancelling"].includes(s.status)) { + s.status = "cancelled"; + count += 1; + } + } + return { rows: [], rowCount: count }; + } + if (sql.startsWith("INSERT INTO tenant_subscriptions")) { + const [id, tenantId, planId, provider, providerRef, periodStart, periodEnd, createdBy] = values as any[]; + // Real schema: GLOBAL UNIQUE(provider, provider_subscription_ref) + if (state.subscriptions.some((s) => s.provider === provider && s.provider_subscription_ref === providerRef)) { + const err = new Error("duplicate key value violates unique constraint \"tenant_subscriptions_provider_ref_unique\"") as any; + err.code = "23505"; + throw err; + } + state.subscriptions.push({ id, tenant_id: tenantId, plan_id: planId, provider, provider_subscription_ref: providerRef, status: "active", current_period_start: periodStart, current_period_end: periodEnd, cancel_at_period_end: false, created_by: createdBy, created_at: new Date() }); + return rows([]); + } + if (sql.includes("FROM tenant_subscriptions s JOIN billing_plans")) { + const sub = [...state.subscriptions] + .filter((s) => s.tenant_id === values[0] && ["pending", "active", "past_due", "cancelling"].includes(s.status)) + .sort((a, b) => b.created_at.getTime() - a.created_at.getTime())[0]; + if (!sub) return rows([]); + const plan = [...state.plans.values()].find((p) => String(p.id) === String(sub.plan_id)); + return rows([{ ...sub, plan_code: plan?.plan_code, display_name: plan?.display_name, billing_interval: plan?.billing_interval, price_kobo: plan?.price_kobo, included_completed_checks: plan?.included_completed_checks }]); + } + + // ── billing_entitlements / usage ──────────────────────────────────────── + if (sql.startsWith("INSERT INTO billing_entitlements")) { + const [id, tenantId, subscriptionId, totalUnits, periodStart, periodEnd, sourceReference] = values as any[]; + // Real schema: GLOBAL UNIQUE(source_reference) + if (state.entitlements.some((e) => e.source_reference === sourceReference)) { + const err = new Error("duplicate key value violates unique constraint \"billing_entitlements_source_reference_key\"") as any; + err.code = "23505"; + throw err; + } + state.entitlements.push({ id, tenant_id: tenantId, subscription_id: subscriptionId, total_units: totalUnits, consumed_units: 0, reserved_units: 0, period_start: periodStart, period_end: periodEnd, status: "active", source_reference: sourceReference }); + return rows([]); + } + if (sql.includes("COALESCE(SUM(total_units)")) { + const active = state.entitlements.filter((e) => e.tenant_id === values[0] && e.status === "active" && new Date(e.period_end).getTime() > Date.now()); + return rows([{ + total_units: active.reduce((n, e) => n + Number(e.total_units), 0), + consumed_units: active.reduce((n, e) => n + Number(e.consumed_units), 0), + reserved_units: active.reduce((n, e) => n + Number(e.reserved_units), 0), + }]); + } + if (sql.includes("FROM billing_usage_events")) { + return rows([{ completed_checks: state.usageEvents.filter((u) => u.tenant_id === values[0]).length }]); + } + + throw new Error(`fakeQuery: unmatched SQL: ${sql}`); + }; +} + +const mocks = vi.hoisted(() => { + const state = makeState(); + const query = vi.fn(fakeQuery(state)); + const connect = vi.fn(async () => ({ query, release: vi.fn() })); + const getPgPool = vi.fn(async () => ({ query, connect })); + return { state, query, connect, getPgPool }; +}); + +vi.mock("./db", () => ({ getPgPool: mocks.getPgPool })); +vi.mock("./permify", () => ({ permifyCheck: vi.fn(async () => true), permifyWriteRelationship: vi.fn(async () => undefined) })); + +// ─── External HTTP boundary (Paystack verify, TigerBeetle, event processor) ── + +const fetchCalls = { paystackVerify: 0, tigerBeetleTransfer: 0, tigerBeetleAccount: 0, events: 0 }; +let paystackBehaviour: "success" | "failed" | "rejected" = "success"; + +function jsonResponse(body: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) } as Response; +} + +function stubFetch() { + vi.stubGlobal("fetch", vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.startsWith("https://api.paystack.co/transaction/verify/")) { + fetchCalls.paystackVerify += 1; + const reference = decodeURIComponent(url.split("/transaction/verify/")[1]); + const intent = mocks.state.intents.get(reference); + if (paystackBehaviour === "rejected") return jsonResponse({ status: false, message: "Could not resolve transaction" }, 404); + if (paystackBehaviour === "failed") { + return jsonResponse({ status: true, data: { status: "failed", amount: intent?.amount_kobo ?? 0, currency: "NGN", reference } }); + } + return jsonResponse({ status: true, data: { status: "success", amount: Number(intent?.amount_kobo ?? 0), currency: "NGN", reference, channel: "card" } }); + } + if (url.startsWith("http://tigerbeetle.test/accounts/create")) { + fetchCalls.tigerBeetleAccount += 1; + return jsonResponse([{ index: 0, result: 0 }]); + } + if (url.startsWith("http://tigerbeetle.test/transfers/create")) { + fetchCalls.tigerBeetleTransfer += 1; + return jsonResponse([{ index: 0, result: 0 }]); + } + if (url.startsWith("http://events.test/v1/events")) { + fetchCalls.events += 1; + return jsonResponse({ accepted: true }); + } + throw new Error(`stubFetch: unexpected URL ${url} ${init?.method ?? "GET"}`); + })); +} + +// ─── Imports after mocks ────────────────────────────────────────────────────── + +import { shareableReportsRouter, __shareableReportsInternals } from "./shareableReports"; +import { selfServiceBillingRouter } from "./selfServiceBilling"; +import type { TrpcContext } from "./_core/context"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeCtx(tenantId: number | null, userId = 10): TrpcContext { + return { + user: { + id: userId, + openId: `wp4-user-${userId}`, + email: `wp4-user-${userId}@example.invalid`, + name: "WP4 Operator", + loginMethod: "keycloak", + role: "admin", + tenantId, + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + } as any, + tenantId, + isDemo: false, + authMethod: "keycloak", + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"], + }; +} + +const PLAN_PRO = { + id: "7", + plan_code: "pro_monthly", + display_name: "Professional Monthly", + billing_interval: "monthly", + price_kobo: 15_000_000, + included_completed_checks: 40, + overage_price_kobo: 250_000, + active: true, + version: 1, +}; + +const REF = "BIS-2026-ABC123"; + +function seedInvestigation(overrides: Partial = {}) { + mocks.state.investigations.set(REF, { + id: 501, + ref: REF, + tenantId: 1, + subjectName: "Adaeze Okonkwo", + status: "completed", + riskTier: null, + riskScore: 82.4, + completedAt: new Date("2026-02-01T10:00:00Z"), + deletedAt: null, + ...overrides, + }); +} + +function seedScreening() { + mocks.state.screening.push( + { investigationRef: REF, tenantId: 1, screeningType: "nin_trace", outcome: "clear", status: "completed" }, + { investigationRef: REF, tenantId: 1, screeningType: "efcc_watchlist", outcome: "adverse", status: "completed" }, + { investigationRef: REF, tenantId: 1, screeningType: "pep_check", outcome: "consider", status: "completed" }, + ); + mocks.state.fieldVisits.push({ investigationId: 501, outcome: "confirmed", submittedAt: new Date("2026-01-30T09:00:00Z") }); +} + +function collectKeys(value: unknown, into = new Set()): Set { + if (Array.isArray(value)) value.forEach((v) => collectKeys(v, into)); + else if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + into.add(k); + collectKeys(v, into); + } + } + return into; +} + +beforeEach(() => { + const fresh = makeState(); + Object.assign(mocks.state, fresh); + mocks.state.tenants = fresh.tenants; + paystackBehaviour = "success"; + fetchCalls.paystackVerify = 0; + fetchCalls.tigerBeetleTransfer = 0; + fetchCalls.tigerBeetleAccount = 0; + fetchCalls.events = 0; + stubFetch(); +}); + +// ─── Share-token lifecycle ──────────────────────────────────────────────────── + +describe("shareableReports — token lifecycle", () => { + it("creates a bis_sl_ token, stores only its SHA-256 hash, and audits + publishes", async () => { + seedInvestigation(); + const caller = shareableReportsRouter.createCaller(makeCtx(1)); + const result = await caller.createShareLink({ investigationRef: REF, expiresInDays: 7 }); + + expect(result.token).toMatch(/^bis_sl_[A-Za-z0-9_-]{32}$/); + expect(result.investigationRef).toBe(REF); + expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now()); + + expect(mocks.state.shareLinks).toHaveLength(1); + const stored = mocks.state.shareLinks[0]; + expect(stored.token_hash).toBe(__shareableReportsInternals.hashShareToken(result.token)); + expect(JSON.stringify(stored)).not.toContain(result.token); + expect(mocks.state.auditLog.some((a) => a.action === "Report share link created" && a.tenantId === 1)).toBe(true); + expect(fetchCalls.events).toBe(1); + }); + + it("serves the redacted report, atomically counting views, until revoked", async () => { + seedInvestigation(); + seedScreening(); + const caller = shareableReportsRouter.createCaller(makeCtx(1)); + const { token, shareLinkId } = await caller.createShareLink({ investigationRef: REF, expiresInDays: 7 }); + + const first = await shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token }); + const second = await shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token }); + expect(first.subjectName).toBe("Adaeze Okonkwo"); + expect(second.subjectName).toBe("Adaeze Okonkwo"); + expect(mocks.state.shareLinks[0].view_count).toBe(2); + expect(mocks.state.shareLinks[0].last_viewed_at).toBeInstanceOf(Date); + + await caller.revokeShareLink({ shareLinkId }); + await expect(shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token })) + .rejects.toMatchObject({ code: "NOT_FOUND" }); + // Revoked links cannot accrue further views. + expect(mocks.state.shareLinks[0].view_count).toBe(2); + }); + + it("rejects expired links", async () => { + seedInvestigation(); + const caller = shareableReportsRouter.createCaller(makeCtx(1)); + const { token } = await caller.createShareLink({ investigationRef: REF, expiresInDays: 1 }); + mocks.state.shareLinks[0].expires_at = new Date(Date.now() - 60_000); + await expect(shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token })) + .rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("rejects unknown tokens and caps expiry at 30 days", async () => { + await expect( + shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token: `bis_sl_${"a".repeat(32)}` }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + seedInvestigation(); + await expect( + shareableReportsRouter.createCaller(makeCtx(1)).createShareLink({ investigationRef: REF, expiresInDays: 31 }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("lists share links tenant-scoped without token hashes", async () => { + seedInvestigation(); + const caller = shareableReportsRouter.createCaller(makeCtx(1)); + await caller.createShareLink({ investigationRef: REF, expiresInDays: 7 }); + await caller.createShareLink({ investigationRef: REF, expiresInDays: 14 }); + + const own = await caller.listShareLinks({}); + expect(own).toHaveLength(2); + expect(own[0].viewCount).toBe(0); + expect(own[0].active).toBe(true); + const keys = collectKeys(own); + expect(keys.has("token_hash")).toBe(false); + expect(keys.has("tokenHash")).toBe(false); + expect(keys.has("created_by")).toBe(false); + + const other = await shareableReportsRouter.createCaller(makeCtx(2)).listShareLinks({}); + expect(other).toHaveLength(0); + }); +}); + +// ─── Redaction shape ────────────────────────────────────────────────────────── + +describe("shareableReports — redaction shape", () => { + it("returns only the whitelisted one-pager and derives band from score", async () => { + seedInvestigation(); + seedScreening(); + const caller = shareableReportsRouter.createCaller(makeCtx(1)); + const { token } = await caller.createShareLink({ investigationRef: REF, expiresInDays: 7 }); + const report = await shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token }); + + expect(report).toEqual({ + subjectName: "Adaeze Okonkwo", + investigationRef: REF, + riskBand: "critical", // derived from 82.4 — the raw score never appears + screening: [ + { source: "efcc_watchlist", outcome: "fail" }, + { source: "nin_trace", outcome: "pass" }, + { source: "pep_check", outcome: "consider" }, + ], + fieldVisit: { outcome: "confirmed", conductedAt: "2026-01-30T09:00:00.000Z" }, + thinFile: false, + generatedAt: expect.any(String), + completedAt: "2026-02-01T10:00:00.000Z", + tenantName: "Acme Verification Ltd", + }); + + const keys = collectKeys(report); + const forbidden = [ + "riskScore", "risk_score", "rawResult", "raw_result", "payload", "summary", + "referee", "refereeName", "source_display_name", "notes", "findings", + "createdBy", "created_by", "userId", "user_id", "agentId", "agentName", + "token", "tokenHash", "token_hash", "nin", "bvn", + ]; + for (const key of forbidden) expect(keys.has(key)).toBe(false); + const serialised = JSON.stringify(report); + expect(serialised).not.toContain("82.4"); + expect(serialised).not.toContain("rawResult"); + expect(serialised).not.toContain("referee"); + }); + + it("flags thin files when no completed screening exists", async () => { + seedInvestigation({ status: "thin_file", riskScore: null, riskTier: null }); + const caller = shareableReportsRouter.createCaller(makeCtx(1)); + const { token } = await caller.createShareLink({ investigationRef: REF, expiresInDays: 7 }); + const report = await shareableReportsRouter.createCaller(makeCtx(null)).getSharedReport({ token }); + expect(report.thinFile).toBe(true); + expect(report.riskBand).toBe("unrated"); + expect(report.screening).toEqual([]); + expect(report.fieldVisit).toBeNull(); + }); +}); + +// ─── Tenant scoping ─────────────────────────────────────────────────────────── + +describe("tenant scoping", () => { + it("denies share creation for another tenant's investigation", async () => { + seedInvestigation({ tenantId: 2 }); + await expect( + shareableReportsRouter.createCaller(makeCtx(1)).createShareLink({ investigationRef: REF, expiresInDays: 7 }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(mocks.state.shareLinks).toHaveLength(0); + }); + + it("denies revoking another tenant's share link", async () => { + seedInvestigation(); + const { shareLinkId } = await shareableReportsRouter.createCaller(makeCtx(1)) + .createShareLink({ investigationRef: REF, expiresInDays: 7 }); + await expect( + shareableReportsRouter.createCaller(makeCtx(2)).revokeShareLink({ shareLinkId }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(mocks.state.shareLinks[0].revoked_at).toBeNull(); + }); + + it("requires an authenticated tenant context for mutations", async () => { + const anonymous = { ...makeCtx(null), user: null }; + await expect( + shareableReportsRouter.createCaller(anonymous).createShareLink({ investigationRef: REF, expiresInDays: 7 }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + // Authenticated but tenant-less operators are also denied. + await expect( + shareableReportsRouter.createCaller(makeCtx(null)).createShareLink({ investigationRef: REF, expiresInDays: 7 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +// ─── Self-service signup ────────────────────────────────────────────────────── + +function seedPaidPlanAndIntent(overrides: Partial = {}) { + mocks.state.plans.set("pro_monthly", { ...PLAN_PRO }); + mocks.state.intents.set("BIS-TOP-ABCDEF0123456789WXYZ0123", { + id: "intent-1", + tenant_id: 1, + provider_reference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + amount_kobo: PLAN_PRO.price_kobo, + currency: "NGN", + purpose: "subscription_invoice", + status: "pending", + expires_at: new Date(Date.now() + 30 * 60 * 1000), + ...overrides, + }); +} + +const TENANT2_REF = "BIS-TOP-ZYXWVU9876543210DCBA9876"; + +function seedTenant2Intent() { + mocks.state.intents.set(TENANT2_REF, { + id: "intent-2", + tenant_id: 2, + provider_reference: TENANT2_REF, + amount_kobo: PLAN_PRO.price_kobo, + currency: "NGN", + purpose: "subscription_invoice", + status: "pending", + expires_at: new Date(Date.now() + 30 * 60 * 1000), + }); +} + +describe("selfServiceBilling — plans and signup", () => { + it("lists the public plan catalogue from billing_plans", async () => { + mocks.state.plans.set("pro_monthly", { ...PLAN_PRO }); + mocks.state.plans.set("starter_monthly", { ...PLAN_PRO, id: "3", plan_code: "starter_monthly", display_name: "Starter", price_kobo: 5_000_000, included_completed_checks: 10 }); + mocks.state.plans.set("retired", { ...PLAN_PRO, id: "9", plan_code: "retired", active: false }); + const plans = await selfServiceBillingRouter.createCaller(makeCtx(null)).listPublicPlans(); + expect(plans.map((p) => p.planCode)).toEqual(["starter_monthly", "pro_monthly"]); + expect(plans[0].currency).toBe("NGN"); + }); + + it("activates a paid subscription through the existing settlement path", async () => { + seedPaidPlanAndIntent(); + const caller = selfServiceBillingRouter.createCaller(makeCtx(1)); + const result = await caller.signup({ + planCode: "pro_monthly", + idempotencyKey: "signup-key-0001", + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + }); + + expect(result.status).toBe("active"); + expect(result.idempotent).toBe(false); + expect(result.billingRef).toBe("BIS-TOP-ABCDEF0123456789WXYZ0123"); + expect(fetchCalls.paystackVerify).toBe(1); + expect(fetchCalls.tigerBeetleTransfer).toBe(1); + + expect(mocks.state.subscriptions).toHaveLength(1); + expect(mocks.state.subscriptions[0].status).toBe("active"); + expect(mocks.state.subscriptions[0].provider).toBe("paystack"); + expect(mocks.state.entitlements).toHaveLength(1); + expect(mocks.state.entitlements[0].total_units).toBe(40); + expect(mocks.state.signups).toHaveLength(1); + expect(mocks.state.intents.get("BIS-TOP-ABCDEF0123456789WXYZ0123")!.status).toBe("credited"); + expect(mocks.state.topups).toHaveLength(1); + expect(mocks.state.auditLog.some((a) => a.action.includes("pro_monthly") && a.result === "success")).toBe(true); + expect(fetchCalls.events).toBe(1); + + const mine = await caller.mySubscription(); + expect(mine.subscription?.planCode).toBe("pro_monthly"); + expect(mine.subscription?.status).toBe("active"); + + const usage = await caller.usageSummary(); + expect(usage).toMatchObject({ tenantId: 1, includedChecks: 40, consumedChecks: 0, reservedChecks: 0, remainingChecks: 40 }); + }); + + it("replays the idempotency key with the original result and no second settlement", async () => { + seedPaidPlanAndIntent(); + const caller = selfServiceBillingRouter.createCaller(makeCtx(1)); + const input = { planCode: "pro_monthly", idempotencyKey: "signup-key-0002", paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123" }; + + const first = await caller.signup(input); + const replay = await caller.signup(input); + + expect(replay.idempotent).toBe(true); + expect(replay.signupId).toBe(first.signupId); + expect(replay.subscriptionId).toBe(first.subscriptionId); + expect(replay.billingRef).toBe(first.billingRef); + expect(fetchCalls.paystackVerify).toBe(1); + expect(fetchCalls.tigerBeetleTransfer).toBe(1); + expect(mocks.state.signups).toHaveLength(1); + expect(mocks.state.subscriptions).toHaveLength(1); + }); + + it("fails closed: a failed payment activates no subscription or entitlement", async () => { + seedPaidPlanAndIntent(); + paystackBehaviour = "failed"; + const caller = selfServiceBillingRouter.createCaller(makeCtx(1)); + + await expect(caller.signup({ + planCode: "pro_monthly", + idempotencyKey: "signup-key-0003", + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + })).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + + expect(mocks.state.subscriptions).toHaveLength(0); + expect(mocks.state.entitlements).toHaveLength(0); + // The failed attempt is durably recorded; it never activates anything. + expect(mocks.state.signups).toHaveLength(1); + expect(mocks.state.signups[0].status).toBe("payment_failed"); + expect(mocks.state.intents.get("BIS-TOP-ABCDEF0123456789WXYZ0123")!.status).toBe("pending"); + expect(mocks.state.topups).toHaveLength(0); + expect(fetchCalls.tigerBeetleTransfer).toBe(0); + expect(mocks.state.auditLog.some((a) => a.result === "failure")).toBe(true); + + const mine = await caller.mySubscription(); + expect(mine.subscription).toBeNull(); + + // Replay of the failed key returns the original failure result and never + // re-settles payment. + const verifyCalls = fetchCalls.paystackVerify; + const replay = await caller.signup({ + planCode: "pro_monthly", + idempotencyKey: "signup-key-0003", + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + }); + expect(replay.idempotent).toBe(true); + expect(replay.status).toBe("payment_failed"); + expect(replay.subscriptionId).toBeNull(); + expect(fetchCalls.paystackVerify).toBe(verifyCalls); + expect(fetchCalls.tigerBeetleTransfer).toBe(0); + expect(mocks.state.subscriptions).toHaveLength(0); + }); + + it("rejects a payment intent bound to another tenant or wrong amount", async () => { + seedPaidPlanAndIntent({ tenant_id: 2 }); + const caller = selfServiceBillingRouter.createCaller(makeCtx(1)); + await expect(caller.signup({ + planCode: "pro_monthly", + idempotencyKey: "signup-key-0004", + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + })).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mocks.state.subscriptions).toHaveLength(0); + + seedPaidPlanAndIntent({ amount_kobo: 1 }); + await expect(caller.signup({ + planCode: "pro_monthly", + idempotencyKey: "signup-key-0005", + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + })).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mocks.state.subscriptions).toHaveLength(0); + }); + + it("rejects unknown plans and paid signups without a payment reference", async () => { + const caller = selfServiceBillingRouter.createCaller(makeCtx(1)); + await expect(caller.signup({ planCode: "ghost_plan", idempotencyKey: "signup-key-0006" })) + .rejects.toMatchObject({ code: "NOT_FOUND" }); + + seedPaidPlanAndIntent(); + await expect(caller.signup({ planCode: "pro_monthly", idempotencyKey: "signup-key-0007" })) + .rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.state.subscriptions).toHaveLength(0); + }); + + it("scopes mySubscription and usageSummary to the caller's tenant", async () => { + seedPaidPlanAndIntent(); + await selfServiceBillingRouter.createCaller(makeCtx(1)).signup({ + planCode: "pro_monthly", + idempotencyKey: "signup-key-0008", + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + }); + const other = selfServiceBillingRouter.createCaller(makeCtx(2)); + expect((await other.mySubscription()).subscription).toBeNull(); + const usage = await other.usageSummary(); + expect(usage.includedChecks).toBe(0); + expect(usage.remainingChecks).toBe(0); + }); + + it("never leaks another tenant's signup on a cross-tenant idempotency-key replay", async () => { + seedPaidPlanAndIntent(); + seedTenant2Intent(); + const SHARED_KEY = "shared-key-cross-tenant"; + + // Tenant 1 signs up with the key. + const first = await selfServiceBillingRouter.createCaller(makeCtx(1)).signup({ + planCode: "pro_monthly", + idempotencyKey: SHARED_KEY, + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + }); + expect(first.status).toBe("active"); + + // Tenant 2 presents the SAME key: it must behave as a brand-new key for + // tenant 2 — never returning tenant 1's signupId, subscription, or + // billing reference. + const foreign = await selfServiceBillingRouter.createCaller(makeCtx(2)).signup({ + planCode: "pro_monthly", + idempotencyKey: SHARED_KEY, + paymentReference: TENANT2_REF, + }); + expect(foreign.idempotent).toBe(false); + expect(foreign.signupId).not.toBe(first.signupId); + expect(foreign.subscriptionId).not.toBe(first.subscriptionId); + expect(foreign.billingRef).toBe(TENANT2_REF); + expect(foreign.billingRef).not.toBe(first.billingRef); + expect(JSON.stringify(foreign)).not.toContain("BIS-TOP-ABCDEF0123456789WXYZ0123"); + expect(JSON.stringify(foreign)).not.toContain(first.signupId); + + // Both tenants now hold their own row under the same key value. + expect(mocks.state.signups).toHaveLength(2); + expect(mocks.state.signups.map((s) => s.tenant_id).sort()).toEqual([1, 2]); + + // Same-tenant replay still returns the original result, untouched. + const replay1 = await selfServiceBillingRouter.createCaller(makeCtx(1)).signup({ + planCode: "pro_monthly", + idempotencyKey: SHARED_KEY, + paymentReference: "BIS-TOP-ABCDEF0123456789WXYZ0123", + }); + expect(replay1.idempotent).toBe(true); + expect(replay1.signupId).toBe(first.signupId); + expect(replay1.subscriptionId).toBe(first.subscriptionId); + expect(replay1.billingRef).toBe(first.billingRef); + + const replay2 = await selfServiceBillingRouter.createCaller(makeCtx(2)).signup({ + planCode: "pro_monthly", + idempotencyKey: SHARED_KEY, + paymentReference: TENANT2_REF, + }); + expect(replay2.idempotent).toBe(true); + expect(replay2.signupId).toBe(foreign.signupId); + expect(replay2.billingRef).toBe(TENANT2_REF); + + // Each tenant settled exactly once; no replay triggered a re-settlement. + expect(mocks.state.subscriptions).toHaveLength(2); + expect(mocks.state.topups).toHaveLength(2); + + // Lock the fix in at the SQL level: every plan_signups lookup must be + // tenant-scoped (regression guard against the original global-key bug). + const replaySelects = mocks.query.mock.calls + .map((c) => String(c[0]).replace(/\s+/g, " ")) + .filter((s) => s.startsWith("SELECT id, plan_code, status, billing_ref FROM plan_signups")); + expect(replaySelects.length).toBeGreaterThan(0); + for (const s of replaySelects) { + expect(s).toContain("tenant_id = $1 AND idempotency_key = $2"); + } + }); + + it("allows two tenants to use the same idempotency key on a FREE plan (tenant-namespaced refs)", async () => { + mocks.state.plans.set("free_monthly", { + id: "1", plan_code: "free_monthly", display_name: "Free", + billing_interval: "monthly", price_kobo: 0, included_completed_checks: 3, + overage_price_kobo: 0, active: true, version: 1, + }); + const SHARED_KEY = "free-key-shared"; + + // Tenant 1 free signup: no payment reference needed or accepted. + const first = await selfServiceBillingRouter.createCaller(makeCtx(1)).signup({ + planCode: "free_monthly", + idempotencyKey: SHARED_KEY, + }); + expect(first.status).toBe("active"); + expect(first.billingRef).toBe(`self-serve-free:1:${SHARED_KEY}`); + expect(fetchCalls.paystackVerify).toBe(0); + expect(fetchCalls.tigerBeetleTransfer).toBe(0); + + // Tenant 2 with the SAME key must succeed with its own subscription — + // the global UNIQUE(provider, provider_subscription_ref) must not collide. + const second = await selfServiceBillingRouter.createCaller(makeCtx(2)).signup({ + planCode: "free_monthly", + idempotencyKey: SHARED_KEY, + }); + expect(second.status).toBe("active"); + expect(second.idempotent).toBe(false); + expect(second.signupId).not.toBe(first.signupId); + expect(second.subscriptionId).not.toBe(first.subscriptionId); + expect(second.billingRef).toBe(`self-serve-free:2:${SHARED_KEY}`); + expect(JSON.stringify(second)).not.toContain(first.billingRef!); + + expect(mocks.state.subscriptions).toHaveLength(2); + expect(new Set(mocks.state.subscriptions.map((s) => s.provider_subscription_ref)).size).toBe(2); + expect(mocks.state.entitlements).toHaveLength(2); + + // Same-tenant replays return each tenant's original result. + const replay1 = await selfServiceBillingRouter.createCaller(makeCtx(1)).signup({ + planCode: "free_monthly", + idempotencyKey: SHARED_KEY, + }); + expect(replay1.idempotent).toBe(true); + expect(replay1.signupId).toBe(first.signupId); + expect(replay1.subscriptionId).toBe(first.subscriptionId); + + const replay2 = await selfServiceBillingRouter.createCaller(makeCtx(2)).signup({ + planCode: "free_monthly", + idempotencyKey: SHARED_KEY, + }); + expect(replay2.idempotent).toBe(true); + expect(replay2.signupId).toBe(second.signupId); + expect(replay2.subscriptionId).toBe(second.subscriptionId); + }); +});