/** * Recurring-subscription logic tests (run with: npx tsx tests/recurring-subscription.test.ts) * * Validates the catalog-driven recurring gating and the pure helpers used by * checkout.ts (method gating, surcharge suppression, recurring line-item build) * and webhooks.ts (invoice -> subscription id extraction). These mirror the * exact logic in the routes so a regression in the rules is caught without a * live Stripe call. The end-to-end Stripe test-mode run is a separate manual * step (documented in docs/billing.md). */ import { COMPLIANCE_SERVICES, type ComplianceService } from "../src/service-catalog.js"; let passed = 0; let failed = 0; function check(name: string, cond: boolean) { if (cond) { passed++; console.log(` ok ${name}`); } else { failed++; console.error(`FAIL ${name}`); } } // ── 1. Catalog: OIG/SAM is recurring, $79/mo, card+ACH only ──────────────── const oig = COMPLIANCE_SERVICES["oig-sam-screening"]; check("oig-sam exists", !!oig); check("oig-sam price is $79 (7900c)", oig.price_cents === 7900); check("oig-sam billing_interval=month", oig.billing_interval === "month"); check("oig-sam allowed_methods = card,ach", JSON.stringify(oig.allowed_methods) === JSON.stringify(["card", "ach"])); check("oig-sam not discountable", oig.discountable === false); check("oig-sam name says Monthly Monitoring", /Monthly Monitoring/.test(oig.name)); // ── 2. Recurring services MUST only allow off-session-capable methods ─────── const OFF_SESSION_OK = new Set(["card", "ach"]); for (const [slug, svc] of Object.entries(COMPLIANCE_SERVICES) as [string, ComplianceService][]) { if (svc.billing_interval) { check(`recurring '${slug}' declares allowed_methods`, Array.isArray(svc.allowed_methods) && svc.allowed_methods.length > 0); const allOk = (svc.allowed_methods || []).every(m => OFF_SESSION_OK.has(m)); check(`recurring '${slug}' methods are all off-session-capable`, allOk); } } // ── 3. One-time services keep mode:payment (no billing_interval) ──────────── check("npi-revalidation is one-time", !COMPLIANCE_SERVICES["npi-revalidation"].billing_interval); check("provider-compliance-bundle is one-time (annual $899)", !COMPLIANCE_SERVICES["provider-compliance-bundle"].billing_interval && COMPLIANCE_SERVICES["provider-compliance-bundle"].price_cents === 89900); // ── 4. Method-gating rule (mirrors checkout.ts METHOD_NOT_ALLOWED) ────────── function methodAllowed(svc: ComplianceService | undefined, method: string): boolean { const allowed = svc?.allowed_methods; return !allowed || allowed.includes(method as any); } check("oig rejects paypal", !methodAllowed(oig, "paypal")); check("oig rejects klarna", !methodAllowed(oig, "klarna")); check("oig rejects crypto", !methodAllowed(oig, "crypto")); check("oig accepts card", methodAllowed(oig, "card")); check("oig accepts ach", methodAllowed(oig, "ach")); check("unrestricted service accepts paypal", methodAllowed(COMPLIANCE_SERVICES["npi-revalidation"], "paypal")); // ── 5. Surcharge suppression for recurring (mirrors checkout.ts) ──────────── const GATEWAY_SURCHARGES: Record = { card: 3.0, ach: 0.0, paypal: 3.0, klarna: 6.0, crypto: 0.0 }; function surchargePct(billingInterval: string | undefined, method: string): number { return billingInterval ? 0 : (GATEWAY_SURCHARGES[method] ?? 0); } check("recurring card surcharge is 0 (absorbed)", surchargePct("month", "card") === 0); check("one-time card surcharge is 3%", surchargePct(undefined, "card") === 3.0); // ── 6. Recurring line-item builder (mirrors checkout.ts mapping) ──────────── type LI = { quantity?: number; price_data: { product_data: { name: string }; unit_amount: number } }; function toRecurring(items: LI[], interval: "month" | "year") { return items.map(li => ({ quantity: li.quantity ?? 1, price_data: { currency: "usd" as const, product_data: li.price_data.product_data, unit_amount: li.price_data.unit_amount, recurring: { interval }, }, })); } const built = toRecurring([{ price_data: { product_data: { name: oig.name }, unit_amount: oig.price_cents } }], "month"); check("recurring line item has recurring.interval=month", built[0].price_data.recurring.interval === "month"); check("recurring line item keeps $79 unit_amount", built[0].price_data.unit_amount === 7900); check("recurring line item defaults quantity=1", built[0].quantity === 1); // ── 7. invoiceSubscriptionId extraction (mirrors webhooks.ts; version-tolerant) function invoiceSubscriptionId(inv: any): string | null { // New API (>= 2026-03-25.dahlia) const modern = inv?.parent?.subscription_details?.subscription; if (modern) return typeof modern === "string" ? modern : modern.id; // Legacy API (<= 2025, incl. account default 2024-12-18.acacia on the // unpinned live endpoint): top-level invoice.subscription const legacy = inv?.subscription; if (legacy) return typeof legacy === "string" ? legacy : legacy.id; return null; } check("subId from string field", invoiceSubscriptionId({ parent: { subscription_details: { subscription: "sub_123" } } }) === "sub_123"); check("subId from object field", invoiceSubscriptionId({ parent: { subscription_details: { subscription: { id: "sub_456" } } } }) === "sub_456"); check("subId from legacy top-level string (acacia)", invoiceSubscriptionId({ subscription: "sub_legacy" }) === "sub_legacy"); check("subId from legacy top-level object", invoiceSubscriptionId({ subscription: { id: "sub_legacy2" } }) === "sub_legacy2"); check("subId prefers modern over legacy", invoiceSubscriptionId({ subscription: "sub_old", parent: { subscription_details: { subscription: "sub_new" } } }) === "sub_new"); check("subId null when no parent", invoiceSubscriptionId({ id: "in_1" }) === null); check("subId null for one-time invoice", invoiceSubscriptionId({ parent: { type: "quote_details" } }) === null); // ── 8. Renewal-cycle gating (mirrors webhooks.ts invoice.paid) ───────────── function shouldFulfillRenewal(billingReason: string): boolean { // First invoice (subscription_create) is handled by checkout.session.completed; // only subscription_cycle should re-dispatch fulfillment. return billingReason === "subscription_cycle"; } check("first invoice (subscription_create) NOT re-fulfilled", !shouldFulfillRenewal("subscription_create")); check("renewal cycle IS re-fulfilled", shouldFulfillRenewal("subscription_cycle")); check("manual invoice NOT re-fulfilled", !shouldFulfillRenewal("manual")); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed === 0 ? 0 : 1);