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:
parent
f481a1d13c
commit
cf021e2f91
21 changed files with 820 additions and 69 deletions
25
api/migrations/100_recurring_subscriptions.sql
Normal file
25
api/migrations/100_recurring_subscriptions.sql
Normal 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;
|
||||||
|
|
@ -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,7 +1506,56 @@ 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 = {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
mode: "payment",
|
||||||
payment_method_types: paymentMethodTypes,
|
payment_method_types: paymentMethodTypes,
|
||||||
line_items: allLineItems,
|
line_items: allLineItems,
|
||||||
|
|
@ -1486,33 +1563,11 @@ router.post("/api/v1/checkout/create-session", async (req, res) => {
|
||||||
customer_email: customer_email || undefined,
|
customer_email: customer_email || undefined,
|
||||||
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`,
|
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" : ""}`,
|
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
|
||||||
metadata: {
|
metadata: sharedMetadata,
|
||||||
order_id,
|
|
||||||
order_type,
|
|
||||||
payment_method,
|
|
||||||
customer_email: customer_email || "",
|
|
||||||
customer_name: customer_name || "",
|
|
||||||
...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}),
|
|
||||||
},
|
|
||||||
payment_intent_data: paymentIntentData,
|
payment_intent_data: paymentIntentData,
|
||||||
// ACH via Financial Connections: collect bank account details only.
|
...(payment_method === "ach" ? { payment_method_options: achPaymentMethodOptions } : {}),
|
||||||
// (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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} : {}),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// (Sales Order already created above, before gateway split)
|
// (Sales Order already created above, before gateway split)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
105
api/tests/recurring-subscription.test.ts
Normal file
105
api/tests/recurring-subscription.test.ts
Normal 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);
|
||||||
|
|
@ -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 & filing, NPPES updates/attestation, and monthly OIG/SAM exclusion screening — one flat annual price, all tracked, all documented.</div>
|
<div style="font-size:13px;color:#065f46;line-height:1.7;">Revalidation monitoring & filing, NPPES updates/attestation, and your <strong>first OIG/SAM exclusion screening</strong> — 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 -->
|
||||||
|
|
|
||||||
|
|
@ -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 — $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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
206
scripts/workers/bundle_upsell.py
Normal file
206
scripts/workers/bundle_upsell.py
Normal 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 — 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 — 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 →
|
||||||
|
</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 — 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()
|
||||||
|
|
@ -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", {})
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} →</span>
|
<span class="font-semibold text-teal-700 whitespace-nowrap">$${svc.price}${svc.suffix || ""} →</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) →
|
Get the Provider Compliance Bundle ($899/yr) →
|
||||||
</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 — all checks passed</h3>
|
ctaContent.innerHTML = `<h3 class="text-lg font-bold text-green-800 mb-2">Looking good — 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 — $99/yr
|
Add monthly OIG/SAM screening — $79/mo
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue