new-site/api/tests/recurring-subscription.test.ts
justin 8af2685d07 fix(webhooks): read invoice.subscription in both API shapes (acacia + dahlia)
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).
2026-06-18 08:42:29 -05:00

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);