feat(healthcare): OIG/SAM exclusion screening as $79/mo Stripe Subscription

Convert OIG/SAM from one-time $299/yr to recurring $79/month (card+ACH only) -
the first real recurring-billing product in the system. Exclusion screening is
a *monthly* federal obligation, so recurring monitoring fits the requirement and
is the biggest valuation lever (vs a one-time annual run).

Catalog (single source of truth):
- service-catalog.ts: add billing_interval + allowed_methods to ComplianceService;
  oig-sam-screening -> 7900c, billing_interval:"month", allowed_methods:[card,ach],
  name "(Monthly Monitoring)".
- gen-service-catalog.py + check-service-catalog-drift.py: carry/guard the two new
  fields; regenerate site catalog.

Checkout (api/src/routes/checkout.ts):
- mode:"subscription" with recurring price_data when billing_interval is set;
  surcharge absorbed for recurring (clean $79/mo); server-side METHOD_NOT_ALLOWED
  re-validation against allowed_methods.
- ensureColumns + migration 100: compliance_orders.stripe_subscription_id,
  bundle_upsell_sent_at (+ subscription index).

Webhooks (api/src/routes/webhooks.ts):
- record stripe_subscription_id on checkout.session.completed (subscription mode).
- invoice.paid (subscription_cycle only) -> re-dispatch screening for the cycle;
  invoice.payment_failed -> admin alert + first-failure customer nudge;
  customer.subscription.deleted -> mark order cancelled. (API 2026-03-25 moved the
  subscription link to invoice.parent.subscription_details.subscription.)

Fulfillment:
- job_server.py: pass recurring_cycle/invoice_id into the order.
- npi_provider.py: OIG handler labels renewal cycles "[Monthly cycle]" + re-screen
  note; bundle action runs only the FIRST screening + flags the $79/mo upsell.

Bundle land-and-expand:
- Provider Compliance Bundle now includes only the first OIG/SAM screening (was
  giving away $948/yr of monitoring inside an $899 bundle).
- new worker scripts/workers/bundle_upsell.py (+ pw-bundle-upsell timer): ~3 weeks
  after a paid bundle, emails the customer to continue $79/mo monitoring; dedup via
  bundle_upsell_sent_at; skips customers who already have an OIG/SAM order.

Surfaces updated to $79/mo: PaymentStep (filters methods, "Billed every month,
cancel anytime"), order pages, healthcare index, npi-compliance-check tool (also
fixed stale $699 bundle drift -> $899), hc_oig_screening + hc_compliance_bundle
emails.

Docs: billing.md gains a "Stripe-native Subscriptions" section + a reality-check
banner (Adyen/ERPNext-gateway model documented there is NOT live; Stripe is the
real rail). Fixed run-migrations.yml container name bug
(performancewest-postgres-1 -> performancewest-api-postgres-1, overridable).

Tests: api/tests/recurring-subscription.test.ts (28 assertions) covers catalog
gating, method validation, surcharge suppression, recurring line-item build,
invoiceSubscriptionId extraction, renewal-cycle gating. tsc clean; site build
clean; catalog drift OK.

Manual deploy step: enable invoice.paid, invoice.payment_failed,
customer.subscription.deleted on the Stripe webhook endpoint.
This commit is contained in:
justin 2026-06-18 07:54:38 -05:00
parent f481a1d13c
commit cf021e2f91
21 changed files with 820 additions and 69 deletions

View file

@ -0,0 +1,25 @@
-- 100: Recurring (Stripe Subscription) support for compliance services.
--
-- The OIG/SAM exclusion screening is sold as a $79/month Stripe Subscription
-- (catalog billing_interval="month"). We track the Stripe Subscription id so the
-- invoice.paid renewal webhook can map a monthly charge back to the order and
-- re-run that cycle's screening.
--
-- The Provider Compliance Bundle ($899/yr) includes only the FIRST OIG/SAM
-- screening; bundle buyers are converted to the $79/mo monitoring subscription
-- after that first cycle via an automated upsell email. bundle_upsell_sent_at
-- dedupes that send.
--
-- Idempotent. (Mirrors ensureColumns() in api/src/routes/checkout.ts so a fresh
-- deploy and this migration converge on the same schema.)
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS stripe_subscription_id text;
ALTER TABLE compliance_orders
ADD COLUMN IF NOT EXISTS bundle_upsell_sent_at timestamptz;
-- Map a renewal invoice's subscription back to its order quickly.
CREATE INDEX IF NOT EXISTS idx_compliance_orders_subscription
ON compliance_orders (stripe_subscription_id)
WHERE stripe_subscription_id IS NOT NULL;

View file

@ -104,6 +104,12 @@ async function ensureColumns(): Promise<void> {
try {
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT`);
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB`);
// Recurring services (e.g. OIG/SAM monthly monitoring) track the Stripe
// Subscription so renewal webhooks (invoice.paid) can map back to the order.
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT`);
// Bundle buyers get the first OIG/SAM screening included, then an automated
// upsell to the $79/mo monitoring subscription — this dedupes that send.
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS bundle_upsell_sent_at TIMESTAMPTZ`);
} catch { /* table may not exist yet */ }
schemaMigrated = true;
}
@ -903,8 +909,30 @@ router.post("/api/v1/checkout/create-session", async (req, res) => {
// For batch orders, total discount comes from the orderData; for single orders, from the order row
const discount_cents = (orderData as any).discount_cents ?? (order.discount_cents as number) ?? 0;
// ── Recurring (subscription) gate ─────────────────────────────────────
// A service flagged billing_interval in the catalog is sold as a Stripe
// Subscription (mode:"subscription"), not a one-time charge. Only methods
// that can do off-session recurring charges (card/ACH) are permitted, and
// only single compliance orders (no batches) can be recurring.
const { COMPLIANCE_SERVICES: CATALOG } = await import("./compliance-orders.js");
const recurringSlug = (order.service_slug as string) || "";
const recurringSvc = order_type === "compliance" ? CATALOG[recurringSlug] : undefined;
const billingInterval = recurringSvc?.billing_interval; // "month" | "year" | undefined
const allowedMethods = recurringSvc?.allowed_methods; // restricts methods if set
if (allowedMethods && !allowedMethods.includes(payment_method as any)) {
res.status(400).json({
error: `${recurringSvc?.name || "This service"} can only be paid by ${allowedMethods.join(" or ")}.`,
code: "METHOD_NOT_ALLOWED",
allowed_methods: allowedMethods,
});
return;
}
// ── Server-side surcharge (applied to post-discount amount) ───────────
const surcharge_pct = GATEWAY_SURCHARGES[payment_method] ?? 0;
// Recurring services bill a clean flat amount ($79/mo) — Stripe subscription
// line items all recur, so a one-time surcharge line is not representable;
// absorb the ~3% card fee instead. ACH is 0% anyway.
const surcharge_pct = billingInterval ? 0 : (GATEWAY_SURCHARGES[payment_method] ?? 0);
const surcharge_cents = Math.round(((base_cents - discount_cents) * surcharge_pct) / 100);
const total_cents = base_cents + surcharge_cents - discount_cents;
@ -1478,41 +1506,68 @@ router.post("/api/v1/checkout/create-session", async (req, res) => {
...(payment_method === "ach" ? { setup_future_usage: "off_session" } : {}),
};
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: paymentMethodTypes,
line_items: allLineItems,
...(discounts ? { discounts } : {}),
customer_email: customer_email || undefined,
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`,
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
metadata: {
order_id,
order_type,
payment_method,
customer_email: customer_email || "",
customer_name: customer_name || "",
...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}),
const sharedMetadata = {
order_id,
order_type,
payment_method,
customer_email: customer_email || "",
customer_name: customer_name || "",
...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}),
};
// ACH via Financial Connections: collect bank account details only.
// (We intentionally do NOT request the 'balances' permission — see note
// below; it requires activating that Stripe product and otherwise errors.)
const achPaymentMethodOptions: Stripe.Checkout.SessionCreateParams.PaymentMethodOptions = {
us_bank_account: {
financial_connections: { permissions: ["payment_method"] },
verification_method: "instant",
},
payment_intent_data: paymentIntentData,
// ACH via Financial Connections: collect bank account details only.
// (We intentionally do NOT request the 'balances' permission: that
// requires activating the Financial Connections "balances" product in the
// Stripe dashboard, and without it Stripe rejects the whole session with
// an invalid_request_error. Plain payment_method collection is enough to
// charge ACH; verification_method:instant still does microdeposit-free
// instant verification where supported.)
...(payment_method === "ach" ? {
payment_method_options: {
us_bank_account: {
financial_connections: {
permissions: ["payment_method"],
},
verification_method: "instant",
};
let session: Stripe.Checkout.Session;
if (billingInterval) {
// ── Recurring (Subscription) checkout ──────────────────────────────
// Convert the one-time line items into recurring prices. No surcharge
// line (absorbed above) so every line recurs cleanly at billingInterval.
const recurringLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] =
stripeLineItems.map((li: any) => ({
quantity: li.quantity ?? 1,
price_data: {
currency: "usd",
product_data: li.price_data.product_data,
unit_amount: li.price_data.unit_amount,
recurring: { interval: billingInterval },
},
},
} : {}),
});
}));
session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: paymentMethodTypes,
line_items: recurringLineItems,
customer_email: customer_email || undefined,
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`,
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}`,
metadata: sharedMetadata,
// Stamp metadata onto the Subscription too, so renewal invoices
// (invoice.paid) can be mapped back to this order downstream.
subscription_data: { metadata: sharedMetadata },
...(payment_method === "ach" ? { payment_method_options: achPaymentMethodOptions } : {}),
});
} else {
session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: paymentMethodTypes,
line_items: allLineItems,
...(discounts ? { discounts } : {}),
customer_email: customer_email || undefined,
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`,
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
metadata: sharedMetadata,
payment_intent_data: paymentIntentData,
...(payment_method === "ach" ? { payment_method_options: achPaymentMethodOptions } : {}),
});
}
// (Sales Order already created above, before gateway split)

View file

@ -423,6 +423,24 @@ router.post(
break;
}
// Subscription checkout: record the Stripe Subscription id on the order
// so renewal invoices (invoice.paid) can map back to it. The session's
// first invoice is paid here; renewals come via invoice.paid.
if (session.mode === "subscription" && session.subscription) {
const subId = typeof session.subscription === "string"
? session.subscription
: session.subscription.id;
try {
await pool.query(
`UPDATE compliance_orders SET stripe_subscription_id = $1 WHERE order_number = $2`,
[subId, order_id],
);
console.log(`[webhooks/stripe] Recorded subscription ${subId} on order ${order_id}`);
} catch (e) {
console.error(`[webhooks/stripe] Failed to record subscription ${subId} on ${order_id}:`, e);
}
}
if (session.payment_status === "paid") {
await handlePaymentComplete(order_id, order_type, session.id);
} else {
@ -487,6 +505,50 @@ router.post(
break;
}
case "invoice.paid": {
// Recurring subscription charge cleared (OIG/SAM monthly monitoring).
// The FIRST invoice is also covered by checkout.session.completed, so
// skip billing_reason "subscription_create" to avoid double-fulfilling;
// act only on the renewal cycles ("subscription_cycle").
const inv = event.data.object as Stripe.Invoice;
const billingReason = inv.billing_reason || "";
const subId = invoiceSubscriptionId(inv);
if (billingReason !== "subscription_cycle") {
console.log(`[webhooks/stripe] invoice.paid (${billingReason}) sub=${subId} — first cycle handled by checkout.session.completed, skipping`);
break;
}
if (!subId) {
console.warn("[webhooks/stripe] invoice.paid with no subscription id", inv.id);
break;
}
await handleSubscriptionRenewal(subId, inv);
break;
}
case "invoice.payment_failed": {
// A renewal charge failed (expired card, NSF). Stripe will retry per
// the dunning settings; alert admin and the customer.
const inv = event.data.object as Stripe.Invoice;
const subId = invoiceSubscriptionId(inv);
console.warn(`[webhooks/stripe] invoice.payment_failed sub=${subId} amount=${inv.amount_due}`);
if (subId) {
handleSubscriptionPaymentFailed(subId, inv).catch(err =>
console.error("[webhooks/stripe] subscription payment-failed handler error:", err),
);
}
break;
}
case "customer.subscription.deleted": {
// Subscription cancelled (by customer, by us, or after dunning gave up).
const sub = event.data.object as Stripe.Subscription;
console.log(`[webhooks/stripe] subscription cancelled: ${sub.id}`);
handleSubscriptionCancelled(sub).catch(err =>
console.error("[webhooks/stripe] subscription-cancel handler error:", err),
);
break;
}
default:
// Ignore unhandled event types
break;
@ -732,6 +794,137 @@ async function handlePaymentFailure(orderId: string, reason: string): Promise<vo
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// Subscription lifecycle (recurring services, e.g. OIG/SAM monthly monitoring)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Extract the Stripe Subscription id from an Invoice. As of API 2026-03-25 the
* subscription link moved under invoice.parent.subscription_details.subscription
* (the top-level invoice.subscription field was removed).
*/
function invoiceSubscriptionId(inv: Stripe.Invoice): string | null {
const sub = inv.parent?.subscription_details?.subscription;
if (!sub) return null;
return typeof sub === "string" ? sub : sub.id;
}
/**
* Look up the compliance order backing a Stripe subscription. Subscriptions are
* recorded on compliance_orders.stripe_subscription_id at checkout completion.
*/
async function findOrderBySubscription(subId: string): Promise<Record<string, any> | null> {
try {
const r = await pool.query(
`SELECT order_number, service_slug, service_name, customer_email, customer_name,
erpnext_sales_order, intake_data, service_fee_cents
FROM compliance_orders
WHERE stripe_subscription_id = $1
ORDER BY created_at DESC LIMIT 1`,
[subId],
);
return r.rows[0] ?? null;
} catch (e) {
console.error(`[subscription] order lookup failed for ${subId}:`, e);
return null;
}
}
/**
* A recurring charge cleared (renewal cycle). Re-run the screening for that
* cycle and email the customer the fresh report. This is the value the
* subscriber pays for each month.
*/
async function handleSubscriptionRenewal(subId: string, inv: Stripe.Invoice): Promise<void> {
const order = await findOrderBySubscription(subId);
if (!order) {
console.warn(`[subscription] renewal for ${subId} — no matching order, skipping fulfillment`);
return;
}
const orderId = order.order_number as string;
const slug = order.service_slug as string;
console.log(`[subscription] renewal cycle for ${orderId} (${slug}) — dispatching re-screen`);
// Re-dispatch the service handler for this cycle. The handler re-runs the
// OIG/SAM screening against the current LEIE + SAM data and emails the report.
const dispatch = await dispatchToWorker("process_compliance_service", {
order_name: order.erpnext_sales_order || orderId,
order_number: orderId,
service_slug: slug,
recurring_cycle: true,
invoice_id: inv.id,
});
if (!dispatch.ok) {
console.error(`[subscription] worker dispatch failed for renewal ${orderId}`);
// Alert admin so the cycle isn't silently missed
await sendEmail({
to: ADMIN_EMAIL,
subject: `⚠️ Subscription renewal fulfillment failed — ${orderId}`,
html: `<p>Renewal charge cleared for subscription <code>${subId}</code> (order ${orderId}, ${slug}) but the worker dispatch failed. Re-run the screening manually.</p>`,
}).catch(() => {});
}
}
/**
* A renewal charge failed. Stripe retries on its dunning schedule; we alert
* admin and (on the first failure) nudge the customer to update their method.
*/
async function handleSubscriptionPaymentFailed(subId: string, inv: Stripe.Invoice): Promise<void> {
const order = await findOrderBySubscription(subId);
const orderId = (order?.order_number as string) || "unknown";
const customerEmail = (order?.customer_email as string) || "";
const attempt = inv.attempt_count || 1;
await sendEmail({
to: ADMIN_EMAIL,
subject: `⚠️ Subscription renewal failed — ${orderId} (attempt ${attempt})`,
html: `<p>Renewal charge failed for subscription <code>${subId}</code> (order ${orderId}).</p>
<p>Amount due: $${((inv.amount_due || 0) / 100).toFixed(2)} Stripe attempt ${attempt}.</p>
<p>Stripe will retry automatically. If retries are exhausted the subscription cancels.</p>`,
}).catch(() => {});
// Customer-facing nudge only on first failure (avoid spamming on each retry).
if (attempt === 1 && customerEmail) {
const updateUrl = inv.hosted_invoice_url || `https://${process.env.DOMAIN || "performancewest.net"}/contact`;
await sendEmail({
to: customerEmail,
subject: "Action needed: your monthly screening payment didn't go through",
html: `<p>Hi ${(order?.customer_name as string) || "there"},</p>
<p>We couldn't process this month's payment for your OIG/SAM exclusion monitoring.
To keep your screening active and your audit record current, please update your
payment details:</p>
<p><a href="${updateUrl}" style="display:inline-block;background:#10b981;color:#fff;font-weight:700;padding:12px 28px;border-radius:8px;text-decoration:none;">Update payment</a></p>
<p>If you have any questions, reply to this email or call (888) 411-0383.</p>
<p>Performance West</p>`,
}).catch(() => {});
}
}
/**
* Subscription cancelled (customer, admin, or dunning gave up). Mark the order
* so monthly fulfillment stops and the portal reflects the cancellation.
*/
async function handleSubscriptionCancelled(sub: Stripe.Subscription): Promise<void> {
const order = await findOrderBySubscription(sub.id);
const orderId = (order?.order_number as string) || "unknown";
try {
await pool.query(
`UPDATE compliance_orders
SET payment_status = 'cancelled',
notes = COALESCE(notes, '') || $2
WHERE stripe_subscription_id = $1`,
[sub.id, `\nSubscription cancelled: ${new Date().toISOString()}`],
);
} catch (e) {
console.error(`[subscription] cancel update failed for ${sub.id}:`, e);
}
await sendEmail({
to: ADMIN_EMAIL,
subject: `Subscription cancelled — ${orderId}`,
html: `<p>Subscription <code>${sub.id}</code> (order ${orderId}) was cancelled. Monthly screening will stop.</p>`,
}).catch(() => {});
}
async function handleACHDispute(paymentIntentId: string, reason: string, amountCents: number): Promise<void> {
try {
// Try to find the order across tables using Stripe session/PI references

View file

@ -20,6 +20,20 @@ export interface ComplianceService {
gov_fee_label?: string;
erpnext_item: string;
discountable: boolean;
/**
* Recurring billing. When set, checkout builds a Stripe Subscription
* (mode:"subscription") that charges price_cents every interval instead of a
* one-time payment. Only payment methods that support off-session recurring
* charges are allowed for these (see allowed_methods).
*/
billing_interval?: "month" | "year";
/**
* Restrict the payment methods offered for this service. Omit = all methods
* (card, ach, paypal, klarna, crypto). Recurring services MUST list only
* methods that can do off-session charges: ["card","ach"] PayPal/Klarna/
* crypto here are one-time only and cannot back a subscription.
*/
allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[];
}
export const COMPLIANCE_SERVICES: Record<string, ComplianceService> = {
@ -535,10 +549,16 @@ export const COMPLIANCE_SERVICES: Record<string, ComplianceService> = {
discountable: true,
},
"oig-sam-screening": {
name: "OIG/SAM Exclusion Screening (Annual)",
price_cents: 29900,
name: "OIG/SAM Exclusion Screening (Monthly Monitoring)",
price_cents: 7900,
erpnext_item: "OIG-SAM-SCREENING",
discountable: false,
// Federal exclusion screening is a MONTHLY obligation (OIG SAB) — sell it as
// recurring monitoring, not a one-time annual run. Recurring requires an
// off-session-capable rail, so only card + ACH (PayPal/Klarna/crypto can't
// back a subscription).
billing_interval: "month",
allowed_methods: ["card", "ach"],
},
"provider-compliance-bundle": {
name: "Provider Compliance Bundle (Annual)",

View file

@ -0,0 +1,105 @@
/**
* 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; API 2026-03-25)
function invoiceSubscriptionId(inv: any): string | null {
const sub = inv?.parent?.subscription_details?.subscription;
if (!sub) return null;
return typeof sub === "string" ? sub : sub.id;
}
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 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);