new-site/api/tests/recurring-subscription.test.ts
justin cf021e2f91 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.
2026-06-18 07:54:38 -05:00

105 lines
5.9 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; 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);