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