The live Stripe webhook endpoint has NO pinned api_version, so it follows the account default (currently 2024-12-18.acacia), which delivers the subscription link as the top-level invoice.subscription. The code only read the new 2026-03-25.dahlia shape (invoice.parent.subscription_details.subscription), so recurring renewal/payment-failed events would have returned a null subscription id and silently failed to fulfill once the events were enabled. invoiceSubscriptionId() now reads the modern shape first, then falls back to the legacy top-level field. All other invoice fields used by the handlers (amount_due, attempt_count, hosted_invoice_url, id) are stable across both versions. +5 tests (legacy string/object, modern-preferred-over-legacy).
113 lines
6.6 KiB
TypeScript
113 lines
6.6 KiB
TypeScript
/**
|
|
* 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<string, number> = { 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);
|