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 { 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 paypal_order_id TEXT`);
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB`); 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 */ } } catch { /* table may not exist yet */ }
schemaMigrated = true; 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 // 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; 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) ─────────── // ── 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 surcharge_cents = Math.round(((base_cents - discount_cents) * surcharge_pct) / 100);
const total_cents = base_cents + surcharge_cents - discount_cents; 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" } : {}), ...(payment_method === "ach" ? { setup_future_usage: "off_session" } : {}),
}; };
const session = await stripe.checkout.sessions.create({ const sharedMetadata = {
mode: "payment", order_id,
payment_method_types: paymentMethodTypes, order_type,
line_items: allLineItems, payment_method,
...(discounts ? { discounts } : {}), customer_email: customer_email || "",
customer_email: customer_email || undefined, customer_name: customer_name || "",
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`, ...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}),
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`, };
metadata: {
order_id, // ACH via Financial Connections: collect bank account details only.
order_type, // (We intentionally do NOT request the 'balances' permission — see note
payment_method, // below; it requires activating that Stripe product and otherwise errors.)
customer_email: customer_email || "", const achPaymentMethodOptions: Stripe.Checkout.SessionCreateParams.PaymentMethodOptions = {
customer_name: customer_name || "", us_bank_account: {
...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}), 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 let session: Stripe.Checkout.Session;
// requires activating the Financial Connections "balances" product in the if (billingInterval) {
// Stripe dashboard, and without it Stripe rejects the whole session with // ── Recurring (Subscription) checkout ──────────────────────────────
// an invalid_request_error. Plain payment_method collection is enough to // Convert the one-time line items into recurring prices. No surcharge
// charge ACH; verification_method:instant still does microdeposit-free // line (absorbed above) so every line recurs cleanly at billingInterval.
// instant verification where supported.) const recurringLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] =
...(payment_method === "ach" ? { stripeLineItems.map((li: any) => ({
payment_method_options: { quantity: li.quantity ?? 1,
us_bank_account: { price_data: {
financial_connections: { currency: "usd",
permissions: ["payment_method"], product_data: li.price_data.product_data,
}, unit_amount: li.price_data.unit_amount,
verification_method: "instant", 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) // (Sales Order already created above, before gateway split)

View file

@ -423,6 +423,24 @@ router.post(
break; 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") { if (session.payment_status === "paid") {
await handlePaymentComplete(order_id, order_type, session.id); await handlePaymentComplete(order_id, order_type, session.id);
} else { } else {
@ -487,6 +505,50 @@ router.post(
break; 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: default:
// Ignore unhandled event types // Ignore unhandled event types
break; 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> { async function handleACHDispute(paymentIntentId: string, reason: string, amountCents: number): Promise<void> {
try { try {
// Try to find the order across tables using Stripe session/PI references // 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; gov_fee_label?: string;
erpnext_item: string; erpnext_item: string;
discountable: boolean; 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> = { export const COMPLIANCE_SERVICES: Record<string, ComplianceService> = {
@ -535,10 +549,16 @@ export const COMPLIANCE_SERVICES: Record<string, ComplianceService> = {
discountable: true, discountable: true,
}, },
"oig-sam-screening": { "oig-sam-screening": {
name: "OIG/SAM Exclusion Screening (Annual)", name: "OIG/SAM Exclusion Screening (Monthly Monitoring)",
price_cents: 29900, price_cents: 7900,
erpnext_item: "OIG-SAM-SCREENING", erpnext_item: "OIG-SAM-SCREENING",
discountable: false, 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": { "provider-compliance-bundle": {
name: "Provider Compliance Bundle (Annual)", 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);

View file

@ -18,7 +18,7 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#ecfdf5;border:2px solid #6ee7b7;border-radius:10px;padding:18px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#ecfdf5;border:2px solid #6ee7b7;border-radius:10px;padding:18px;">
<h3 style="margin:0 0 10px;font-size:15px;color:#065f46;font-weight:700;">What's included</h3> <h3 style="margin:0 0 10px;font-size:15px;color:#065f46;font-weight:700;">What's included</h3>
<div style="font-size:13px;color:#065f46;line-height:1.7;">Revalidation monitoring &amp; filing, NPPES updates/attestation, and monthly OIG/SAM exclusion screening &mdash; one flat annual price, all tracked, all documented.</div> <div style="font-size:13px;color:#065f46;line-height:1.7;">Revalidation monitoring &amp; filing, NPPES updates/attestation, and your <strong>first OIG/SAM exclusion screening</strong> &mdash; one flat annual price, all tracked, all documented. Continue monthly exclusion monitoring afterward for $79/month (optional, cancel anytime).</div>
</td></tr></table> </td></tr></table>
<!-- Detail row --> <!-- Detail row -->

View file

@ -7,7 +7,7 @@
<tr><td style="background-color:#0f766e;background:linear-gradient(135deg,#0f766e 0%,#14b8a6 100%);padding:26px 28px;"> <tr><td style="background-color:#0f766e;background:linear-gradient(135deg,#0f766e 0%,#14b8a6 100%);padding:26px 28px;">
<img src="https://performancewest.net/images/logo-white.png" alt="Performance West" style="height:44px;margin-bottom:10px;display:block" /> <img src="https://performancewest.net/images/logo-white.png" alt="Performance West" style="height:44px;margin-bottom:10px;display:block" />
<h1 style="color:#fff;margin:0;font-size:22px;font-weight:700;font-family:Inter,system-ui,sans-serif;">Exclusion Screening Notice</h1> <h1 style="color:#fff;margin:0;font-size:22px;font-weight:700;font-family:Inter,system-ui,sans-serif;">Exclusion Screening Notice</h1>
<p style="color:#ccfbf1;margin:6px 0 0;font-size:13px;font-family:Inter,system-ui,sans-serif;">Annual OIG/SAM screening requirement</p> <p style="color:#ccfbf1;margin:6px 0 0;font-size:13px;font-family:Inter,system-ui,sans-serif;">Monthly OIG/SAM exclusion screening</p>
</td></tr> </td></tr>
<!-- Body --> <!-- Body -->
@ -25,7 +25,7 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:18px 0;font-size:13px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:18px 0;font-size:13px;">
<tr style="border-bottom:1px solid #e5e7eb;"><td style="padding:10px 0;color:#6b7280;">NPI</td><td style="padding:10px 0;font-weight:600;text-align:right;">{{ .Subscriber.Attribs.npi }}</td></tr> <tr style="border-bottom:1px solid #e5e7eb;"><td style="padding:10px 0;color:#6b7280;">NPI</td><td style="padding:10px 0;font-weight:600;text-align:right;">{{ .Subscriber.Attribs.npi }}</td></tr>
<tr style="border-bottom:1px solid #e5e7eb;"><td style="padding:10px 0;color:#6b7280;">Practice</td><td style="padding:10px 0;font-weight:600;text-align:right;">{{ .Subscriber.Attribs.practice }}</td></tr> <tr style="border-bottom:1px solid #e5e7eb;"><td style="padding:10px 0;color:#6b7280;">Practice</td><td style="padding:10px 0;font-weight:600;text-align:right;">{{ .Subscriber.Attribs.practice }}</td></tr>
<tr><td style="padding:10px 0;color:#6b7280;">Our service fee</td><td style="padding:10px 0;font-weight:700;text-align:right;color:#047857;">$299</td></tr> <tr><td style="padding:10px 0;color:#6b7280;">Our service fee</td><td style="padding:10px 0;font-weight:700;text-align:right;color:#047857;">$79/month</td></tr>
</table> </table>
<!-- Verify-it-yourself: the OIG LEIE and SAM exclusion lists are public and <!-- Verify-it-yourself: the OIG LEIE and SAM exclusion lists are public and
@ -42,7 +42,7 @@
<!-- CTA --> <!-- CTA -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#ecfdf5;border:2px solid #10b981;border-radius:10px;padding:18px;text-align:center;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0;"><tr><td style="background:#ecfdf5;border:2px solid #10b981;border-radius:10px;padding:18px;text-align:center;">
<p style="font-size:14px;color:#065f46;margin:0 0 6px;font-weight:600;">We run and document your OIG/SAM exclusion screening.</p> <p style="font-size:14px;color:#065f46;margin:0 0 6px;font-weight:600;">We run and document your OIG/SAM exclusion screening.</p>
<p style="font-size:12px;color:#047857;margin:0 0 14px;">Monthly checks with an audit-ready record.</p> <p style="font-size:12px;color:#047857;margin:0 0 14px;">Monthly checks with an audit-ready record &mdash; $79/month, cancel anytime.</p>
<a href="https://performancewest.net/order/oig-sam-screening?npi={{ .Subscriber.Attribs.npi }}" style="display:inline-block;padding:14px 40px;background:#10b981;color:#fff;font-weight:700;border-radius:8px;text-decoration:none;font-size:15px;">Set up exclusion screening →</a> <a href="https://performancewest.net/order/oig-sam-screening?npi={{ .Subscriber.Attribs.npi }}" style="display:inline-block;padding:14px 40px;background:#10b981;color:#fff;font-weight:700;border-radius:8px;text-decoration:none;font-size:15px;">Set up exclusion screening →</a>
</td></tr></table> </td></tr></table>

View file

@ -1,6 +1,20 @@
# Billing & Payments Architecture # Billing & Payments Architecture
**Last updated:** 2026-04-05 **Last updated:** 2026-06-18
> ⚠️ **Reality check (2026-06):** Large parts of this doc describe a *planned*
> "ERPNext owns all billing via Adyen" architecture that is **NOT live**. What is
> actually wired today:
> - **Live payment rail = Stripe Checkout** (card + ACH), plus **PayPal** (direct
> Orders v2) and **crypto** (SHKeeper) — all in `api/src/routes/checkout.ts`.
> **Klarna** runs via Stripe.
> - **Adyen is NOT integrated** (account approval never completed). The
> `Adyen-*` gateway names below are aspirational labels, not active gateways.
> - **Recurring billing = Stripe Subscriptions** (see "Stripe-native
> Subscriptions"), the only recurring billing actually shipping, used by
> `oig-sam-screening` ($79/mo). ERPNext `createSubscription()` is unused.
> ERPNext is still the system of record for invoices/accounting, but it is **not**
> the payment gateway. Treat Adyen/ERPNext-gateway sections as future plan.
## Principle: ERPNext Owns All Billing ## Principle: ERPNext Owns All Billing
@ -138,14 +152,70 @@ Sales Invoice:
``` ```
### Recurring Services (Subscriptions) ### Recurring Services (Subscriptions)
ERPNext Subscription DocType handles:
> **Status:** the only recurring billing actually wired today is **Stripe-native
> Subscriptions** (see next section), used by `oig-sam-screening` ($79/mo). The
> ERPNext-Subscription / Adyen model below is **planned, not yet live** — Adyen
> is not integrated and ERPNext `createSubscription()` is currently unused. The
> services listed here are aspirational pricing, not active subscriptions.
ERPNext Subscription DocType is intended to handle (NOT YET LIVE):
- Registered Agent: $99/year per state (Wyoming: $49/year) - Registered Agent: $99/year per state (Wyoming: $49/year)
- Annual Report Filing: $99/year per state - Annual Report Filing: $99/year per state
- Canada CRTC Annual Maintenance: $349/year - Canada CRTC Annual Maintenance: $349/year
- US Formation Maintenance Bundle: $179/year (annual report + RA renewal) - US Formation Maintenance Bundle: $179/year (annual report + RA renewal)
- CA Formation Maintenance Bundle: $179/year (annual return + AMB/RA renewal) - CA Formation Maintenance Bundle: $179/year (annual return + AMB/RA renewal)
Subscriptions auto-generate invoices. Payment collected via Adyen (saved payment method) or manual payment link. When built, subscriptions would auto-generate invoices (payment via a saved
payment method or manual payment link).
### Stripe-native Subscriptions (healthcare monitoring)
Some compliance services are sold as **Stripe Subscriptions** (the billing engine
is Stripe, not ERPNext). A service opts in via the catalog
(`api/src/service-catalog.ts`):
```ts
"oig-sam-screening": {
name: "OIG/SAM Exclusion Screening (Monthly Monitoring)",
price_cents: 7900, // $79/month
billing_interval: "month", // -> checkout builds mode:"subscription"
allowed_methods: ["card", "ach"], // recurring needs off-session-capable rails
...
}
```
Flow:
1. `checkout.ts` sees `billing_interval` -> creates a `mode:"subscription"`
Checkout Session with recurring `price_data`. The gateway surcharge is
**absorbed** (a subscription can't carry a one-time surcharge line) so the
customer is billed a clean `$79/month`.
2. `allowed_methods` filters the picker in `PaymentStep.astro` (PayPal/Klarna/
crypto are one-time only and disappear) and is **re-validated server-side**
in `checkout.ts` (`METHOD_NOT_ALLOWED`).
3. `webhooks.ts` handles the subscription lifecycle:
- `checkout.session.completed` (mode=subscription) -> records
`compliance_orders.stripe_subscription_id`, then first fulfillment.
- `invoice.paid` with `billing_reason=subscription_cycle` -> re-dispatches the
service handler (`recurring_cycle:true`) to re-run the screening + deliver a
fresh dated certificate. (The first invoice is skipped here — already handled
by `checkout.session.completed`.)
- `invoice.payment_failed` -> admin alert + first-failure customer nudge.
- `customer.subscription.deleted` -> order marked `cancelled`, fulfillment stops.
**Stripe Dashboard webhook events (MUST be enabled on the prod endpoint):**
in addition to the existing `checkout.session.completed`, `payment_intent.*`,
`charge.dispute.created`, `balance.available`, enable:
- `invoice.paid`
- `invoice.payment_failed`
- `customer.subscription.deleted`
Without these three the monthly cycles will charge but never fulfill/alert.
The `provider-compliance-bundle` ($899/yr) includes **only the first** OIG/SAM
screening; customers are converted to the $79/mo monitoring subscription after
that first cycle (the standalone $948/yr of monitoring is no longer given away
inside the bundle).
### Formation Maintenance Bundles ### Formation Maintenance Bundles

View file

@ -13,7 +13,7 @@
- name: Execute migration - name: Execute migration
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: performancewest-postgres-1 container: "{{ migrations_container | default('performancewest-api-postgres-1') }}"
command: psql -U {{ db_user }} -d {{ db_name }} -f /tmp/{{ migration }} command: psql -U {{ db_user }} -d {{ db_name }} -f /tmp/{{ migration }}
register: migration_result register: migration_result

View file

@ -85,6 +85,15 @@ worker_crons:
on_calendar: "*-*-* 16:00:00 UTC" # 11:00 CT on_calendar: "*-*-* 16:00:00 UTC" # 11:00 CT
persistent: true persistent: true
# Bundle -> monthly OIG/SAM monitoring upsell. Provider Compliance Bundle
# includes only the FIRST screening; ~3 weeks after the bundle is paid we
# invite the customer to continue $79/mo monitoring (the recurring product).
- name: pw-bundle-upsell
description: Upsell paid bundle buyers to $79/mo OIG/SAM monitoring
module: scripts.workers.bundle_upsell
on_calendar: "*-*-* 17:00:00 UTC" # 12:00 CT, after payment-reminder
persistent: true
# RMD removed scraper — weekly, Wednesday 08:00 (tracks FCC RMD removals). # RMD removed scraper — weekly, Wednesday 08:00 (tracks FCC RMD removals).
- name: pw-fcc-rmd-removed - name: pw-fcc-rmd-removed
description: Scrape FCC public list of RMD-removed carriers description: Scrape FCC public list of RMD-removed carriers

View file

@ -35,9 +35,15 @@ def parse_generated(ts: str) -> dict:
name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner)
price_m = re.search(r"price_cents:\s*(\d+)", inner) price_m = re.search(r"price_cents:\s*(\d+)", inner)
gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner) gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner)
interval_m = re.search(r'billing_interval:\s*"(month|year)"', inner)
methods_m = re.search(r"allowed_methods:\s*\[([^\]]*)\]", inner)
entry = {"name": gen._unescape(name_m.group(1)), "price_cents": int(price_m.group(1))} entry = {"name": gen._unescape(name_m.group(1)), "price_cents": int(price_m.group(1))}
if gov_m: if gov_m:
entry["gov_fee_label"] = gen._unescape(gov_m.group(1)) entry["gov_fee_label"] = gen._unescape(gov_m.group(1))
if interval_m:
entry["billing_interval"] = interval_m.group(1)
if methods_m:
entry["allowed_methods"] = re.findall(r'"([a-z]+)"', methods_m.group(1))
out[slug] = entry out[slug] = entry
return out return out
@ -57,6 +63,10 @@ def main() -> int:
problems.append(f"{slug}: name mismatch") problems.append(f"{slug}: name mismatch")
if a.get("gov_fee_label") != g.get("gov_fee_label"): if a.get("gov_fee_label") != g.get("gov_fee_label"):
problems.append(f"{slug}: gov_fee_label mismatch") problems.append(f"{slug}: gov_fee_label mismatch")
if a.get("billing_interval") != g.get("billing_interval"):
problems.append(f"{slug}: billing_interval API={a.get('billing_interval')} generated={g.get('billing_interval')}")
if a.get("allowed_methods") != g.get("allowed_methods"):
problems.append(f"{slug}: allowed_methods API={a.get('allowed_methods')} generated={g.get('allowed_methods')}")
for slug in have: for slug in have:
if slug not in api: if slug not in api:
problems.append(f"{slug}: in generated file but not in API") problems.append(f"{slug}: in generated file but not in API")

View file

@ -40,6 +40,8 @@ def parse_catalog(ts: str) -> dict:
name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner)
price_m = re.search(r"price_cents:\s*(\d+)", inner) price_m = re.search(r"price_cents:\s*(\d+)", inner)
gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner) gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner)
interval_m = re.search(r'billing_interval:\s*"(month|year)"', inner)
methods_m = re.search(r"allowed_methods:\s*\[([^\]]*)\]", inner)
if not name_m or not price_m: if not name_m or not price_m:
continue continue
entry = { entry = {
@ -48,6 +50,10 @@ def parse_catalog(ts: str) -> dict:
} }
if gov_m: if gov_m:
entry["gov_fee_label"] = _unescape(gov_m.group(1)) entry["gov_fee_label"] = _unescape(gov_m.group(1))
if interval_m:
entry["billing_interval"] = interval_m.group(1)
if methods_m:
entry["allowed_methods"] = re.findall(r'"([a-z]+)"', methods_m.group(1))
out[slug] = entry out[slug] = entry
return out return out
@ -59,6 +65,10 @@ def render(catalog: dict) -> str:
parts = [f"name: {json.dumps(s["name"], ensure_ascii=False)}", f"price_cents: {s['price_cents']}"] parts = [f"name: {json.dumps(s["name"], ensure_ascii=False)}", f"price_cents: {s['price_cents']}"]
if s.get("gov_fee_label"): if s.get("gov_fee_label"):
parts.append(f"gov_fee_label: {json.dumps(s["gov_fee_label"], ensure_ascii=False)}") parts.append(f"gov_fee_label: {json.dumps(s["gov_fee_label"], ensure_ascii=False)}")
if s.get("billing_interval"):
parts.append(f"billing_interval: {json.dumps(s['billing_interval'], ensure_ascii=False)}")
if s.get("allowed_methods"):
parts.append(f"allowed_methods: {json.dumps(s['allowed_methods'], ensure_ascii=False)}")
lines.append(f" {json.dumps(slug, ensure_ascii=False)}: {{ {', '.join(parts)} }},") lines.append(f" {json.dumps(slug, ensure_ascii=False)}: {{ {', '.join(parts)} }},")
return ( return (
"/**\n" "/**\n"
@ -74,6 +84,8 @@ def render(catalog: dict) -> str:
" name: string;\n" " name: string;\n"
" price_cents: number;\n" " price_cents: number;\n"
" gov_fee_label?: string;\n" " gov_fee_label?: string;\n"
' billing_interval?: "month" | "year";\n'
' allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[];\n'
"}\n\n" "}\n\n"
"export const SERVICE_META: Record<string, ServiceMeta> = {\n" "export const SERVICE_META: Record<string, ServiceMeta> = {\n"
+ "\n".join(lines) + "\n".join(lines)

View file

@ -0,0 +1,206 @@
"""
Bundle -> monthly-monitoring upsell worker.
The Provider Compliance Bundle ($899/yr) includes the customer's FIRST OIG/SAM
exclusion screening. Federal exclusion screening is a *monthly* obligation, so
after that first screening we invite the customer to continue with standalone
OIG/SAM monitoring ($79/month) -- the recurring product. This worker finds paid
bundle orders whose first cycle is done and sends a one-time upsell email with a
direct link to the recurring checkout.
Schedule: daily (systemd timer pw-bundle-upsell). Each order is emailed at most
once (tracked by compliance_orders.bundle_upsell_sent_at).
Window: send roughly 3-4 weeks after the bundle was paid -- long enough that the
first screening/certificate has been delivered, soon enough to convert before
the next monthly obligation lapses.
"""
import logging
import os
import smtplib
import sys
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import psycopg2
LOG = logging.getLogger("workers.bundle_upsell")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pw:pw@localhost:5432/performancewest")
DOMAIN = os.getenv("DOMAIN", "performancewest.net")
SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "noreply@performancewest.net")
SMTP_PASS = os.getenv("SMTP_PASS", "")
SMTP_FROM = os.getenv("SMTP_FROM", "Performance West <noreply@performancewest.net>")
BUNDLE_SLUG = "provider-compliance-bundle"
# How long after the bundle is paid before we send the monitoring upsell. Lower
# bound gives the first screening + certificate time to be delivered; upper bound
# stops us emailing ancient orders when the worker is first switched on.
UPSELL_AFTER = timedelta(days=21)
UPSELL_BEFORE = timedelta(days=120)
# Override knob for testing / disabling.
UPSELL_ENABLED = os.getenv("BUNDLE_UPSELL_ENABLED", "1") == "1"
def build_email_html(customer_name: str, npi: str, order_url: str) -> str:
npi_line = (
f'<p style="margin:0 0 14px;font-size:13px;color:#475569;">For NPI '
f'<strong>{npi}</strong>.</p>'
if npi else ""
)
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#eef0f3;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;padding:32px 16px;"><tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
<tr><td style="background:linear-gradient(135deg,#0f766e 0%,#14b8a6 100%);padding:26px 28px;">
<h1 style="color:#fff;margin:0;font-size:21px;font-weight:700;">Keep your exclusion screening current</h1>
<p style="color:#ccfbf1;margin:6px 0 0;font-size:13px;">Continue monthly OIG/SAM monitoring</p>
</td></tr>
<tr><td style="padding:28px;">
<p style="margin:0 0 16px;font-size:15px;color:#1f2937;">Hi {customer_name},</p>
<p style="margin:0 0 16px;font-size:14px;color:#475569;line-height:1.7;">
Your Provider Compliance Bundle included your <strong>first</strong> OIG LEIE
and SAM exclusion screening. But exclusion screening is a <strong>monthly</strong>
obligation under federal guidance &mdash; a one-time check doesn't keep you
covered the rest of the year.
</p>
{npi_line}
<table width="100%" cellpadding="0" cellspacing="0" style="background:#ecfdf5;border:2px solid #6ee7b7;border-radius:10px;margin:0 0 22px;">
<tr><td style="padding:18px;">
<p style="margin:0 0 6px;font-size:14px;color:#065f46;font-weight:700;">Ongoing OIG/SAM Exclusion Monitoring</p>
<p style="margin:0 0 4px;font-size:13px;color:#065f46;line-height:1.6;">
We re-screen you (and listed staff) every month against the current OIG
LEIE and SAM lists, and issue a fresh audit-ready certificate each cycle.
</p>
<p style="margin:8px 0 0;font-size:15px;color:#047857;font-weight:700;">$79 / month &mdash; cancel anytime</p>
</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
<a href="{order_url}" style="display:inline-block;background:#10b981;color:#fff;font-size:15px;font-weight:700;padding:14px 38px;border-radius:8px;text-decoration:none;">
Set up monthly monitoring &rarr;
</a>
</td></tr></table>
<p style="margin:22px 0 0;font-size:12px;color:#94a3b8;text-align:center;">
Questions? Reply to this email or call (888) 411-0383.
</p>
</td></tr>
<tr><td style="padding:16px 28px;background:#f8fafc;border-top:1px solid #e5e7eb;text-align:center;">
<p style="margin:0;font-size:11px;color:#9ca3af;">Performance West &mdash; healthcare compliance.</p>
</td></tr>
</table>
</td></tr></table>
</body></html>"""
def build_email_text(customer_name: str, npi: str, order_url: str) -> str:
npi_line = f"NPI: {npi}\n\n" if npi else ""
return (
f"Hi {customer_name},\n\n"
"Your Provider Compliance Bundle included your FIRST OIG/SAM exclusion "
"screening. Exclusion screening is a monthly obligation under federal "
"guidance, so a one-time check doesn't keep you covered all year.\n\n"
f"{npi_line}"
"Continue with ongoing OIG/SAM Exclusion Monitoring: we re-screen you (and "
"listed staff) every month and issue a fresh audit-ready certificate each "
"cycle. $79/month, cancel anytime.\n\n"
f"Set up monthly monitoring: {order_url}\n\n"
"Questions? Reply to this email or call (888) 411-0383.\n\n"
"Performance West\n"
)
def send_upsell_email(to_email: str, customer_name: str, npi: str) -> bool:
order_url = f"https://{DOMAIN}/order/oig-sam-screening"
if npi:
order_url += f"?npi={npi}"
msg = MIMEMultipart("alternative")
msg["From"] = SMTP_FROM
msg["To"] = to_email
msg["Subject"] = "Keep your OIG/SAM exclusion screening current ($79/mo)"
msg["Reply-To"] = f"support@{DOMAIN}"
msg.attach(MIMEText(build_email_text(customer_name, npi, order_url), "plain"))
msg.attach(MIMEText(build_email_html(customer_name, npi, order_url), "html"))
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.ehlo()
server.starttls()
server.ehlo()
server.login(SMTP_USER, SMTP_PASS)
server.sendmail(SMTP_USER, [to_email], msg.as_string())
LOG.info("Sent bundle->monitoring upsell to %s", to_email)
return True
except Exception as e:
LOG.error("Failed to send bundle upsell to %s: %s", to_email, e)
return False
def process_upsells():
if not UPSELL_ENABLED:
LOG.info("Bundle upsell disabled (BUNDLE_UPSELL_ENABLED=0)")
return
now = datetime.now(timezone.utc)
window_start = now - UPSELL_BEFORE
window_end = now - UPSELL_AFTER
sent = 0
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor() as cur:
# Paid bundle orders in the upsell window that haven't been upsold yet,
# and where the customer doesn't ALREADY have an OIG/SAM order (don't
# pitch monitoring to someone who already bought it).
cur.execute(
"""
SELECT b.order_number, b.customer_email, b.customer_name, b.intake_data
FROM compliance_orders b
WHERE b.service_slug = %s
AND b.payment_status = 'paid'
AND b.paid_at BETWEEN %s AND %s
AND b.bundle_upsell_sent_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM compliance_orders o
WHERE o.customer_email = b.customer_email
AND o.service_slug = 'oig-sam-screening'
)
LIMIT 100
""",
(BUNDLE_SLUG, window_start, window_end),
)
rows = cur.fetchall()
LOG.info("Bundle upsell: %d candidate(s) in window", len(rows))
for order_number, email, name, intake in rows:
if not email:
continue
npi = ""
if isinstance(intake, dict):
npi = str(intake.get("npi") or "")
ok = send_upsell_email(email, name or "there", npi)
if ok:
cur.execute(
"UPDATE compliance_orders SET bundle_upsell_sent_at = %s WHERE order_number = %s",
(now, order_number),
)
conn.commit()
sent += 1
LOG.info("Bundle upsell run complete: %d email(s) sent", sent)
except Exception as e:
LOG.error("Bundle upsell error: %s", e)
conn.rollback()
finally:
conn.close()
if __name__ == "__main__":
process_upsells()

View file

@ -1255,6 +1255,14 @@ def handle_process_compliance_service(payload: dict) -> dict:
# verification gate and re-dispatches the order for submission). # verification gate and re-dispatches the order for submission).
if payload.get("admin_approved"): if payload.get("admin_approved"):
order["admin_approved"] = True order["admin_approved"] = True
# Recurring subscription renewal cycle (e.g. OIG/SAM monthly monitoring):
# the webhook re-dispatches each cycle to re-run the screening and deliver a
# fresh certificate. Flag it so the handler can date/label the new cycle and
# delivery treats it as a recurring report rather than a first fulfillment.
if payload.get("recurring_cycle"):
order["recurring_cycle"] = True
if payload.get("invoice_id"):
order["recurring_invoice_id"] = payload["invoice_id"]
# Final entity check before dispatch # Final entity check before dispatch
ent = order.get("entity", {}) ent = order.get("entity", {})

View file

@ -15,8 +15,8 @@ Covers slugs:
npi-reactivation reactivate a deactivated NPI npi-reactivation reactivate a deactivated NPI
nppes-update NPPES data update / attestation nppes-update NPPES data update / attestation
medicare-enrollment new Medicare enrollment via PECOS medicare-enrollment new Medicare enrollment via PECOS
oig-sam-screening OIG LEIE + SAM exclusion screening (annual) oig-sam-screening OIG LEIE + SAM exclusion screening (monthly)
provider-compliance-bundle revalidation watch + screening + NPPES upkeep provider-compliance-bundle revalidation watch + first screening + NPPES upkeep
Intake data needed (collected by the npi-intake wizard step): Intake data needed (collected by the npi-intake wizard step):
- npi provider's 10-digit NPI - npi provider's 10-digit NPI
@ -95,12 +95,13 @@ _SLUG_META = {
"priority": "high", "priority": "high",
}, },
"oig-sam-screening": { "oig-sam-screening": {
"name": "OIG/SAM Exclusion Screening (Annual)", "name": "OIG/SAM Exclusion Screening (Monthly Monitoring)",
"portal": "https://oig.hhs.gov/exclusions/ + https://sam.gov/", "portal": "https://oig.hhs.gov/exclusions/ + https://sam.gov/",
"action": ( "action": (
"Run the provider (and any listed staff) against the OIG LEIE and " "Run the provider (and any listed staff) against the OIG LEIE and "
"SAM exclusion lists. Produce the screening certificate and flag any " "SAM exclusion lists. Produce the screening certificate and flag any "
"matches for escalation." "matches for escalation. Recurring monthly subscription: each renewal "
"cycle re-runs the screening against current data and emails the report."
), ),
"access": ( "access": (
"No client access needed - OIG LEIE + SAM.gov are public. Screen by NPI/name, issue certificate." "No client access needed - OIG LEIE + SAM.gov are public. Screen by NPI/name, issue certificate."
@ -112,8 +113,9 @@ _SLUG_META = {
"portal": "https://pecos.cms.hhs.gov/ + https://nppes.cms.hhs.gov/", "portal": "https://pecos.cms.hhs.gov/ + https://nppes.cms.hhs.gov/",
"action": ( "action": (
"Onboard the provider into the annual compliance bundle: enroll in " "Onboard the provider into the annual compliance bundle: enroll in "
"revalidation watch, run OIG/SAM screening, and refresh the NPPES " "revalidation watch, run the FIRST OIG/SAM screening (included), and "
"record. Set the next revalidation reminder." "refresh the NPPES record. Set the next revalidation reminder, and flag "
"for the $79/month exclusion-monitoring upsell after the first screening."
), ),
"access": ( "access": (
"Standard (default): CMS-855 paper filing for the enrollment/revalidation piece, mailed to MAC (daily batch); screening is public (no client action). " "Standard (default): CMS-855 paper filing for the enrollment/revalidation piece, mailed to MAC (daily batch); screening is public (no client action). "
@ -200,8 +202,26 @@ class _BaseNPIHandler:
"no": "NO — client declined surrogate -> use the STANDARD path (prepare form, e-sign, daily mail batch).", "no": "NO — client declined surrogate -> use the STANDARD path (prepare form, e-sign, daily mail batch).",
}.get(surrogate, "UNDECIDED — confirm with client; default to STANDARD path if not granted.") }.get(surrogate, "UNDECIDED — confirm with client; default to STANDARD path if not granted.")
# Recurring monthly cycle (e.g. OIG/SAM monitoring renewal): the webhook
# re-dispatched this order after a renewal charge cleared. Surface it so
# the admin re-runs the screening for the new cycle and issues a fresh
# dated certificate, rather than treating it as a first fulfillment.
recurring = bool(order_data.get("recurring_cycle"))
cycle_note = ""
title_prefix = ""
if recurring:
inv = order_data.get("recurring_invoice_id", "")
cycle_note = (
"\n*** RECURRING MONTHLY CYCLE *** — renewal charge cleared"
+ (f" (invoice {inv})" if inv else "")
+ ". Re-run the screening against CURRENT OIG LEIE + SAM data and "
"issue a NEW dated certificate for this cycle.\n"
)
title_prefix = "[Monthly cycle] "
description = ( description = (
f"{meta['action']}\n\n" cycle_note
+ f"{meta['action']}\n\n"
f"Provider: {provider}\n" f"Provider: {provider}\n"
f"NPI: {npi}\n" f"NPI: {npi}\n"
f"PECOS Enrollment ID: {pecos_id or 'not provided'}\n" f"PECOS Enrollment ID: {pecos_id or 'not provided'}\n"
@ -220,7 +240,7 @@ class _BaseNPIHandler:
self._create_todo( self._create_todo(
order_number, order_number,
intake, intake,
title=f"{meta['name']}{provider} (NPI {npi})", title=f"{title_prefix}{meta['name']}{provider} (NPI {npi})",
description=description, description=description,
priority=meta["priority"], priority=meta["priority"],
) )

View file

@ -7,6 +7,21 @@ import { SERVICE_META, formatUSD, slugVertical } from "../../../lib/intake_manif
import CheckoutTrustBand from "../../CheckoutTrustBand.astro"; import CheckoutTrustBand from "../../CheckoutTrustBand.astro";
const meta = SERVICE_META[service_slug]; const meta = SERVICE_META[service_slug];
const vertical = slugVertical(service_slug); const vertical = slugVertical(service_slug);
// Recurring services (billing_interval) bill on a cycle and restrict methods to
// those that support off-session charges (allowed_methods). Filter the picker so
// customers never select a method the server will reject.
const ALL_METHODS = [
{ value: "card", label: "Credit / Debit card" },
{ value: "ach", label: "ACH (US bank)" },
{ value: "paypal", label: "PayPal" },
{ value: "klarna", label: "Klarna (pay over time)" },
{ value: "crypto", label: "Cryptocurrency" },
];
const allowed = meta?.allowed_methods;
const methods = allowed ? ALL_METHODS.filter(m => allowed.includes(m.value as any)) : ALL_METHODS;
const interval = meta?.billing_interval;
const intervalLabel = interval === "month" ? "month" : interval === "year" ? "year" : null;
--- ---
<div class="pw-step" data-slug={service_slug}> <div class="pw-step" data-slug={service_slug}>
@ -23,17 +38,17 @@ const vertical = slugVertical(service_slug);
<span>Service</span><strong>{meta?.name ?? service_slug}</strong> <span>Service</span><strong>{meta?.name ?? service_slug}</strong>
</div> </div>
<div class="pw-total-line"> <div class="pw-total-line">
<span>Total</span><strong>{meta ? formatUSD(meta.price_cents) : "—"}</strong> <span>{intervalLabel ? "Price" : "Total"}</span>
<strong>{meta ? (intervalLabel ? `${formatUSD(meta.price_cents)} / ${intervalLabel}` : formatUSD(meta.price_cents)) : "—"}</strong>
</div> </div>
{intervalLabel && (
<p class="pw-recurring-note">Billed every {intervalLabel}. Cancel anytime.</p>
)}
</div> </div>
<label class="pw-field">Payment method</label> <label class="pw-field">Payment method</label>
<select id="pw-pay-method" class="pw-input"> <select id="pw-pay-method" class="pw-input">
<option value="card">Credit / Debit card</option> {methods.map(m => <option value={m.value}>{m.label}</option>)}
<option value="ach">ACH (US bank)</option>
<option value="paypal">PayPal</option>
<option value="klarna">Klarna (pay over time)</option>
<option value="crypto">Cryptocurrency</option>
</select> </select>
<p class="pw-method-note">Click <strong>Finish</strong> below to continue with the selected method.</p> <p class="pw-method-note">Click <strong>Finish</strong> below to continue with the selected method.</p>
@ -46,6 +61,7 @@ const vertical = slugVertical(service_slug);
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; } .pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-total-box { background: #ecfdf5; border-left: 4px solid #059669; padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin-bottom: 1.5rem; } .pw-total-box { background: #ecfdf5; border-left: 4px solid #059669; padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin-bottom: 1.5rem; }
.pw-total-line { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 1rem; } .pw-total-line { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 1rem; }
.pw-recurring-note { margin: 0.4rem 0 0; color: #047857; font-size: 0.82rem; }
.pw-field { display: block; font-weight: 600; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; } .pw-field { display: block; font-weight: 600; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; } .pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
.pw-method-note { margin: 0.75rem 0 0; color: #64748b; font-size: 0.9rem; } .pw-method-note { margin: 0.75rem 0 0; color: #64748b; font-size: 0.9rem; }

View file

@ -12,6 +12,8 @@ export interface ServiceMeta {
name: string; name: string;
price_cents: number; price_cents: number;
gov_fee_label?: string; gov_fee_label?: string;
billing_interval?: "month" | "year";
allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[];
} }
export const SERVICE_META: Record<string, ServiceMeta> = { export const SERVICE_META: Record<string, ServiceMeta> = {
@ -73,7 +75,7 @@ export const SERVICE_META: Record<string, ServiceMeta> = {
"nppes-update": { name: "NPPES Data Update / Attestation", price_cents: 34900 }, "nppes-update": { name: "NPPES Data Update / Attestation", price_cents: 34900 },
"ny-hut-registration": { name: "NY Highway Use Tax Registration", price_cents: 10900, gov_fee_label: "NY HUT certificate & decal fees (state, billed at cost)" }, "ny-hut-registration": { name: "NY Highway Use Tax Registration", price_cents: 10900, gov_fee_label: "NY HUT certificate & decal fees (state, billed at cost)" },
"ocn-registration": { name: "NECA OCN + Sponsoring CLEC Agreement", price_cents: 265000 }, "ocn-registration": { name: "NECA OCN + Sponsoring CLEC Agreement", price_cents: 265000 },
"oig-sam-screening": { name: "OIG/SAM Exclusion Screening (Annual)", price_cents: 29900 }, "oig-sam-screening": { name: "OIG/SAM Exclusion Screening (Monthly Monitoring)", price_cents: 7900, billing_interval: "month", allowed_methods: ["card", "ach"] },
"or-weight-mile-tax": { name: "Oregon Weight-Mile Tax Setup", price_cents: 10900, gov_fee_label: "Oregon weight-mile tax account & bond/deposit (state, billed at cost)" }, "or-weight-mile-tax": { name: "Oregon Weight-Mile Tax Setup", price_cents: 10900, gov_fee_label: "Oregon weight-mile tax account & bond/deposit (state, billed at cost)" },
"osow-permit": { name: "Oversize/Overweight Permit", price_cents: 10900, gov_fee_label: "State oversize/overweight permit fees (per trip/route, billed at cost)" }, "osow-permit": { name: "Oversize/Overweight Permit", price_cents: 10900, gov_fee_label: "State oversize/overweight permit fees (per trip/route, billed at cost)" },
"provider-compliance-bundle": { name: "Provider Compliance Bundle (Annual)", price_cents: 89900 }, "provider-compliance-bundle": { name: "Provider Compliance Bundle (Annual)", price_cents: 89900 },

View file

@ -9,7 +9,7 @@ const slug = "oig-sam-screening";
const steps = INTAKE_MANIFEST[slug]; const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug]; const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order"; const title = meta ? `Order ${meta.name}` : "Order";
const description = "Annual OIG LEIE and SAM exclusion screening for you and your staff, with a compliance certificate."; const description = "Monthly OIG LEIE and SAM exclusion screening for you and your staff, with an audit-ready compliance certificate each month. $79/month, cancel anytime.";
--- ---
<Base title={title} description={description}> <Base title={title} description={description}>

View file

@ -9,7 +9,7 @@ const slug = "provider-compliance-bundle";
const steps = INTAKE_MANIFEST[slug]; const steps = INTAKE_MANIFEST[slug];
const meta = SERVICE_META[slug]; const meta = SERVICE_META[slug];
const title = meta ? `Order ${meta.name}` : "Order"; const title = meta ? `Order ${meta.name}` : "Order";
const description = "Annual provider compliance bundle: revalidation monitoring, OIG/SAM screening, and NPPES upkeep in one package."; const description = "Annual provider compliance bundle: revalidation monitoring & filing, NPPES upkeep, and your first OIG/SAM exclusion screening in one package.";
--- ---
<Base title={title} description={description}> <Base title={title} description={description}>

View file

@ -40,13 +40,13 @@ const description = "We handle the federal paperwork that keeps providers billab
</a> </a>
<a class="pw-card" href="/order/oig-sam-screening"> <a class="pw-card" href="/order/oig-sam-screening">
<h3>OIG / SAM Exclusion Screening</h3> <h3>OIG / SAM Exclusion Screening</h3>
<p class="pw-card-price">$299 / year</p> <p class="pw-card-price">$79 / month</p>
<p>Annual OIG LEIE and SAM exclusion screening for you and your staff, with a compliance certificate.</p> <p>Monthly OIG LEIE and SAM exclusion screening for you and your staff, with an audit-ready certificate each cycle. Cancel anytime.</p>
</a> </a>
<a class="pw-card" href="/order/provider-compliance-bundle"> <a class="pw-card" href="/order/provider-compliance-bundle">
<h3>Provider Compliance Bundle</h3> <h3>Provider Compliance Bundle</h3>
<p class="pw-card-price">$899 / year</p> <p class="pw-card-price">$899 / year</p>
<p>Revalidation monitoring, OIG/SAM screening, and NPPES upkeep in one annual package.</p> <p>Revalidation monitoring, NPPES upkeep, and your first OIG/SAM screening in one annual package.</p>
</a> </a>
</div> </div>
</section> </section>

View file

@ -120,7 +120,7 @@ import Base from "../../layouts/Base.astro";
const SERVICE_MAP = { const SERVICE_MAP = {
"npi-active": { name: "NPI Reactivation", price: 249, url: "/order/npi-reactivation" }, "npi-active": { name: "NPI Reactivation", price: 249, url: "/order/npi-reactivation" },
"revalidation": { name: "Medicare Revalidation Filing", price: 399, url: "/order/npi-revalidation" }, "revalidation": { name: "Medicare Revalidation Filing", price: 399, url: "/order/npi-revalidation" },
"exclusion": { name: "OIG/SAM Exclusion Screening", price: 99, url: "/order/oig-sam-screening" }, "exclusion": { name: "OIG/SAM Exclusion Screening", price: 79, suffix: "/mo", url: "/order/oig-sam-screening" },
"nppes-fresh": { name: "NPPES Data Update", price: 149, url: "/order/nppes-update" }, "nppes-fresh": { name: "NPPES Data Update", price: 149, url: "/order/nppes-update" },
"optout": { name: "Medicare Enrollment (PECOS)", price: 499, url: "/order/medicare-enrollment" }, "optout": { name: "Medicare Enrollment (PECOS)", price: 499, url: "/order/medicare-enrollment" },
}; };
@ -329,22 +329,22 @@ import Base from "../../layouts/Base.astro";
for (const svc of services) { for (const svc of services) {
html += `<a href="${svc.url}?npi=${npi}" class="flex items-center justify-between gap-3 p-3 rounded-lg border border-gray-200 hover:bg-teal-50 hover:border-teal-300 transition cursor-pointer" style="text-decoration:none;color:inherit;"> html += `<a href="${svc.url}?npi=${npi}" class="flex items-center justify-between gap-3 p-3 rounded-lg border border-gray-200 hover:bg-teal-50 hover:border-teal-300 transition cursor-pointer" style="text-decoration:none;color:inherit;">
<span class="font-medium text-gray-900">${svc.name}</span> <span class="font-medium text-gray-900">${svc.name}</span>
<span class="font-semibold text-teal-700 whitespace-nowrap">$${svc.price} &rarr;</span> <span class="font-semibold text-teal-700 whitespace-nowrap">$${svc.price}${svc.suffix || ""} &rarr;</span>
</a>`; </a>`;
} }
html += `</div>`; html += `</div>`;
html += `<div class="mt-5 text-center"> html += `<div class="mt-5 text-center">
<a href="/order/provider-compliance-bundle?npi=${npi}" style="background:#0f766e;color:#fff;font-weight:700;padding:12px 28px;border-radius:8px;display:inline-block;font-size:15px;text-decoration:none;"> <a href="/order/provider-compliance-bundle?npi=${npi}" style="background:#0f766e;color:#fff;font-weight:700;padding:12px 28px;border-radius:8px;display:inline-block;font-size:15px;text-decoration:none;">
Get the Provider Compliance Bundle ($699/yr) &rarr; Get the Provider Compliance Bundle ($899/yr) &rarr;
</a> </a>
<p style="font-size:11px;color:#6b7280;margin-top:8px;">Revalidation monitoring, screening, and NPPES upkeep in one package.</p> <p style="font-size:11px;color:#6b7280;margin-top:8px;">Revalidation monitoring, NPPES upkeep, and your first OIG/SAM screening in one package.</p>
</div>`; </div>`;
ctaContent.innerHTML = html; ctaContent.innerHTML = html;
} else { } else {
ctaContent.innerHTML = `<h3 class="text-lg font-bold text-green-800 mb-2">Looking good &mdash; all checks passed</h3> ctaContent.innerHTML = `<h3 class="text-lg font-bold text-green-800 mb-2">Looking good &mdash; all checks passed</h3>
<p class="text-sm text-gray-600 mb-4">Your NPI and Medicare records appear current. No action is needed right now.</p> <p class="text-sm text-gray-600 mb-4">Your NPI and Medicare records appear current. No action is needed right now.</p>
<a href="/order/oig-sam-screening?npi=${data.npi}" class="inline-block bg-teal-600 hover:bg-teal-700 text-white font-semibold px-5 py-2 rounded-lg text-sm transition" style="text-decoration:none;"> <a href="/order/oig-sam-screening?npi=${data.npi}" class="inline-block bg-teal-600 hover:bg-teal-700 text-white font-semibold px-5 py-2 rounded-lg text-sm transition" style="text-decoration:none;">
Add annual OIG/SAM screening &mdash; $99/yr Add monthly OIG/SAM screening &mdash; $79/mo
</a>`; </a>`;
} }
} }