diff --git a/api/migrations/100_recurring_subscriptions.sql b/api/migrations/100_recurring_subscriptions.sql new file mode 100644 index 0000000..6834ff9 --- /dev/null +++ b/api/migrations/100_recurring_subscriptions.sql @@ -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; diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index 719d37f..08fe296 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -104,6 +104,12 @@ async function ensureColumns(): Promise { try { await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT`); await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB`); + // Recurring services (e.g. OIG/SAM monthly monitoring) track the Stripe + // Subscription so renewal webhooks (invoice.paid) can map back to the order. + await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT`); + // Bundle buyers get the first OIG/SAM screening included, then an automated + // upsell to the $79/mo monitoring subscription — this dedupes that send. + await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS bundle_upsell_sent_at TIMESTAMPTZ`); } catch { /* table may not exist yet */ } schemaMigrated = true; } @@ -903,8 +909,30 @@ router.post("/api/v1/checkout/create-session", async (req, res) => { // For batch orders, total discount comes from the orderData; for single orders, from the order row const discount_cents = (orderData as any).discount_cents ?? (order.discount_cents as number) ?? 0; + // ── Recurring (subscription) gate ───────────────────────────────────── + // A service flagged billing_interval in the catalog is sold as a Stripe + // Subscription (mode:"subscription"), not a one-time charge. Only methods + // that can do off-session recurring charges (card/ACH) are permitted, and + // only single compliance orders (no batches) can be recurring. + const { COMPLIANCE_SERVICES: CATALOG } = await import("./compliance-orders.js"); + const recurringSlug = (order.service_slug as string) || ""; + const recurringSvc = order_type === "compliance" ? CATALOG[recurringSlug] : undefined; + const billingInterval = recurringSvc?.billing_interval; // "month" | "year" | undefined + const allowedMethods = recurringSvc?.allowed_methods; // restricts methods if set + if (allowedMethods && !allowedMethods.includes(payment_method as any)) { + res.status(400).json({ + error: `${recurringSvc?.name || "This service"} can only be paid by ${allowedMethods.join(" or ")}.`, + code: "METHOD_NOT_ALLOWED", + allowed_methods: allowedMethods, + }); + return; + } + // ── Server-side surcharge (applied to post-discount amount) ─────────── - const surcharge_pct = GATEWAY_SURCHARGES[payment_method] ?? 0; + // Recurring services bill a clean flat amount ($79/mo) — Stripe subscription + // line items all recur, so a one-time surcharge line is not representable; + // absorb the ~3% card fee instead. ACH is 0% anyway. + const surcharge_pct = billingInterval ? 0 : (GATEWAY_SURCHARGES[payment_method] ?? 0); const surcharge_cents = Math.round(((base_cents - discount_cents) * surcharge_pct) / 100); const total_cents = base_cents + surcharge_cents - discount_cents; @@ -1478,41 +1506,68 @@ router.post("/api/v1/checkout/create-session", async (req, res) => { ...(payment_method === "ach" ? { setup_future_usage: "off_session" } : {}), }; - const session = await stripe.checkout.sessions.create({ - mode: "payment", - payment_method_types: paymentMethodTypes, - line_items: allLineItems, - ...(discounts ? { discounts } : {}), - customer_email: customer_email || undefined, - success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`, - cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`, - metadata: { - order_id, - order_type, - payment_method, - customer_email: customer_email || "", - customer_name: customer_name || "", - ...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}), + const sharedMetadata = { + order_id, + order_type, + payment_method, + customer_email: customer_email || "", + customer_name: customer_name || "", + ...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}), + }; + + // ACH via Financial Connections: collect bank account details only. + // (We intentionally do NOT request the 'balances' permission — see note + // below; it requires activating that Stripe product and otherwise errors.) + const achPaymentMethodOptions: Stripe.Checkout.SessionCreateParams.PaymentMethodOptions = { + us_bank_account: { + financial_connections: { permissions: ["payment_method"] }, + verification_method: "instant", }, - payment_intent_data: paymentIntentData, - // ACH via Financial Connections: collect bank account details only. - // (We intentionally do NOT request the 'balances' permission: that - // requires activating the Financial Connections "balances" product in the - // Stripe dashboard, and without it Stripe rejects the whole session with - // an invalid_request_error. Plain payment_method collection is enough to - // charge ACH; verification_method:instant still does microdeposit-free - // instant verification where supported.) - ...(payment_method === "ach" ? { - payment_method_options: { - us_bank_account: { - financial_connections: { - permissions: ["payment_method"], - }, - verification_method: "instant", + }; + + let session: Stripe.Checkout.Session; + if (billingInterval) { + // ── Recurring (Subscription) checkout ────────────────────────────── + // Convert the one-time line items into recurring prices. No surcharge + // line (absorbed above) so every line recurs cleanly at billingInterval. + const recurringLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = + stripeLineItems.map((li: any) => ({ + quantity: li.quantity ?? 1, + price_data: { + currency: "usd", + product_data: li.price_data.product_data, + unit_amount: li.price_data.unit_amount, + recurring: { interval: billingInterval }, }, - }, - } : {}), - }); + })); + + session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: paymentMethodTypes, + line_items: recurringLineItems, + customer_email: customer_email || undefined, + success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`, + cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}`, + metadata: sharedMetadata, + // Stamp metadata onto the Subscription too, so renewal invoices + // (invoice.paid) can be mapped back to this order downstream. + subscription_data: { metadata: sharedMetadata }, + ...(payment_method === "ach" ? { payment_method_options: achPaymentMethodOptions } : {}), + }); + } else { + session = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: paymentMethodTypes, + line_items: allLineItems, + ...(discounts ? { discounts } : {}), + customer_email: customer_email || undefined, + success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`, + cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`, + metadata: sharedMetadata, + payment_intent_data: paymentIntentData, + ...(payment_method === "ach" ? { payment_method_options: achPaymentMethodOptions } : {}), + }); + } // (Sales Order already created above, before gateway split) diff --git a/api/src/routes/webhooks.ts b/api/src/routes/webhooks.ts index ae26368..7029050 100644 --- a/api/src/routes/webhooks.ts +++ b/api/src/routes/webhooks.ts @@ -423,6 +423,24 @@ router.post( break; } + // Subscription checkout: record the Stripe Subscription id on the order + // so renewal invoices (invoice.paid) can map back to it. The session's + // first invoice is paid here; renewals come via invoice.paid. + if (session.mode === "subscription" && session.subscription) { + const subId = typeof session.subscription === "string" + ? session.subscription + : session.subscription.id; + try { + await pool.query( + `UPDATE compliance_orders SET stripe_subscription_id = $1 WHERE order_number = $2`, + [subId, order_id], + ); + console.log(`[webhooks/stripe] Recorded subscription ${subId} on order ${order_id}`); + } catch (e) { + console.error(`[webhooks/stripe] Failed to record subscription ${subId} on ${order_id}:`, e); + } + } + if (session.payment_status === "paid") { await handlePaymentComplete(order_id, order_type, session.id); } else { @@ -487,6 +505,50 @@ router.post( break; } + case "invoice.paid": { + // Recurring subscription charge cleared (OIG/SAM monthly monitoring). + // The FIRST invoice is also covered by checkout.session.completed, so + // skip billing_reason "subscription_create" to avoid double-fulfilling; + // act only on the renewal cycles ("subscription_cycle"). + const inv = event.data.object as Stripe.Invoice; + const billingReason = inv.billing_reason || ""; + const subId = invoiceSubscriptionId(inv); + if (billingReason !== "subscription_cycle") { + console.log(`[webhooks/stripe] invoice.paid (${billingReason}) sub=${subId} — first cycle handled by checkout.session.completed, skipping`); + break; + } + if (!subId) { + console.warn("[webhooks/stripe] invoice.paid with no subscription id", inv.id); + break; + } + await handleSubscriptionRenewal(subId, inv); + break; + } + + case "invoice.payment_failed": { + // A renewal charge failed (expired card, NSF). Stripe will retry per + // the dunning settings; alert admin and the customer. + const inv = event.data.object as Stripe.Invoice; + const subId = invoiceSubscriptionId(inv); + console.warn(`[webhooks/stripe] invoice.payment_failed sub=${subId} amount=${inv.amount_due}`); + if (subId) { + handleSubscriptionPaymentFailed(subId, inv).catch(err => + console.error("[webhooks/stripe] subscription payment-failed handler error:", err), + ); + } + break; + } + + case "customer.subscription.deleted": { + // Subscription cancelled (by customer, by us, or after dunning gave up). + const sub = event.data.object as Stripe.Subscription; + console.log(`[webhooks/stripe] subscription cancelled: ${sub.id}`); + handleSubscriptionCancelled(sub).catch(err => + console.error("[webhooks/stripe] subscription-cancel handler error:", err), + ); + break; + } + default: // Ignore unhandled event types break; @@ -732,6 +794,137 @@ async function handlePaymentFailure(orderId: string, reason: string): Promise | 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 { + 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: `

Renewal charge cleared for subscription ${subId} (order ${orderId}, ${slug}) but the worker dispatch failed. Re-run the screening manually.

`, + }).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 { + 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: `

Renewal charge failed for subscription ${subId} (order ${orderId}).

+

Amount due: $${((inv.amount_due || 0) / 100).toFixed(2)} — Stripe attempt ${attempt}.

+

Stripe will retry automatically. If retries are exhausted the subscription cancels.

`, + }).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: `

Hi ${(order?.customer_name as string) || "there"},

+

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:

+

Update payment

+

If you have any questions, reply to this email or call (888) 411-0383.

+

Performance West

`, + }).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 { + 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: `

Subscription ${sub.id} (order ${orderId}) was cancelled. Monthly screening will stop.

`, + }).catch(() => {}); +} + async function handleACHDispute(paymentIntentId: string, reason: string, amountCents: number): Promise { try { // Try to find the order across tables using Stripe session/PI references diff --git a/api/src/service-catalog.ts b/api/src/service-catalog.ts index 3de381b..041bda3 100644 --- a/api/src/service-catalog.ts +++ b/api/src/service-catalog.ts @@ -20,6 +20,20 @@ export interface ComplianceService { gov_fee_label?: string; erpnext_item: string; discountable: boolean; + /** + * Recurring billing. When set, checkout builds a Stripe Subscription + * (mode:"subscription") that charges price_cents every interval instead of a + * one-time payment. Only payment methods that support off-session recurring + * charges are allowed for these (see allowed_methods). + */ + billing_interval?: "month" | "year"; + /** + * Restrict the payment methods offered for this service. Omit = all methods + * (card, ach, paypal, klarna, crypto). Recurring services MUST list only + * methods that can do off-session charges: ["card","ach"] — PayPal/Klarna/ + * crypto here are one-time only and cannot back a subscription. + */ + allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[]; } export const COMPLIANCE_SERVICES: Record = { @@ -535,10 +549,16 @@ export const COMPLIANCE_SERVICES: Record = { discountable: true, }, "oig-sam-screening": { - name: "OIG/SAM Exclusion Screening (Annual)", - price_cents: 29900, + name: "OIG/SAM Exclusion Screening (Monthly Monitoring)", + price_cents: 7900, erpnext_item: "OIG-SAM-SCREENING", discountable: false, + // Federal exclusion screening is a MONTHLY obligation (OIG SAB) — sell it as + // recurring monitoring, not a one-time annual run. Recurring requires an + // off-session-capable rail, so only card + ACH (PayPal/Klarna/crypto can't + // back a subscription). + billing_interval: "month", + allowed_methods: ["card", "ach"], }, "provider-compliance-bundle": { name: "Provider Compliance Bundle (Annual)", diff --git a/api/tests/recurring-subscription.test.ts b/api/tests/recurring-subscription.test.ts new file mode 100644 index 0000000..32545c3 --- /dev/null +++ b/api/tests/recurring-subscription.test.ts @@ -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 = { 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); diff --git a/data/hc_campaigns/hc_compliance_bundle.html b/data/hc_campaigns/hc_compliance_bundle.html index 61639db..aa54ad7 100644 --- a/data/hc_campaigns/hc_compliance_bundle.html +++ b/data/hc_campaigns/hc_compliance_bundle.html @@ -18,7 +18,7 @@

What's included

-
Revalidation monitoring & filing, NPPES updates/attestation, and monthly OIG/SAM exclusion screening — one flat annual price, all tracked, all documented.
+
Revalidation monitoring & filing, NPPES updates/attestation, and your first OIG/SAM exclusion screening — one flat annual price, all tracked, all documented. Continue monthly exclusion monitoring afterward for $79/month (optional, cancel anytime).
diff --git a/data/hc_campaigns/hc_oig_screening.html b/data/hc_campaigns/hc_oig_screening.html index e2135e2..86cdd7b 100644 --- a/data/hc_campaigns/hc_oig_screening.html +++ b/data/hc_campaigns/hc_oig_screening.html @@ -7,7 +7,7 @@ Performance West

Exclusion Screening Notice

-

Annual OIG/SAM screening requirement

+

Monthly OIG/SAM exclusion screening

@@ -25,7 +25,7 @@ - +
NPI{{ .Subscriber.Attribs.npi }}
Practice{{ .Subscriber.Attribs.practice }}
Our service fee$299
Our service fee$79/month

We run and document your OIG/SAM exclusion screening.

-

Monthly checks with an audit-ready record.

+

Monthly checks with an audit-ready record — $79/month, cancel anytime.

Set up exclusion screening →
diff --git a/docs/billing.md b/docs/billing.md index 14e53ef..796f31b 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -1,6 +1,20 @@ # 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 @@ -138,14 +152,70 @@ Sales Invoice: ``` ### 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) - Annual Report Filing: $99/year per state - Canada CRTC Annual Maintenance: $349/year - US Formation Maintenance Bundle: $179/year (annual report + 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 diff --git a/infra/ansible/playbooks/run-migrations.yml b/infra/ansible/playbooks/run-migrations.yml index 3e9936f..da5306a 100644 --- a/infra/ansible/playbooks/run-migrations.yml +++ b/infra/ansible/playbooks/run-migrations.yml @@ -13,7 +13,7 @@ - name: Execute migration 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 }} register: migration_result diff --git a/infra/ansible/roles/worker-crons/defaults/main.yml b/infra/ansible/roles/worker-crons/defaults/main.yml index 512ce96..da8ff53 100644 --- a/infra/ansible/roles/worker-crons/defaults/main.yml +++ b/infra/ansible/roles/worker-crons/defaults/main.yml @@ -85,6 +85,15 @@ worker_crons: on_calendar: "*-*-* 16:00:00 UTC" # 11:00 CT 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). - name: pw-fcc-rmd-removed description: Scrape FCC public list of RMD-removed carriers diff --git a/scripts/check-service-catalog-drift.py b/scripts/check-service-catalog-drift.py index ec9e5ec..62e35c2 100644 --- a/scripts/check-service-catalog-drift.py +++ b/scripts/check-service-catalog-drift.py @@ -35,9 +35,15 @@ def parse_generated(ts: str) -> dict: name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) price_m = re.search(r"price_cents:\s*(\d+)", 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))} if gov_m: 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 return out @@ -57,6 +63,10 @@ def main() -> int: problems.append(f"{slug}: name mismatch") if a.get("gov_fee_label") != g.get("gov_fee_label"): 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: if slug not in api: problems.append(f"{slug}: in generated file but not in API") diff --git a/scripts/gen-service-catalog.py b/scripts/gen-service-catalog.py index 9726ffe..dbf6059 100644 --- a/scripts/gen-service-catalog.py +++ b/scripts/gen-service-catalog.py @@ -40,6 +40,8 @@ def parse_catalog(ts: str) -> dict: name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) price_m = re.search(r"price_cents:\s*(\d+)", 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: continue entry = { @@ -48,6 +50,10 @@ def parse_catalog(ts: str) -> dict: } if gov_m: 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 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']}"] if s.get("gov_fee_label"): 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)} }},") return ( "/**\n" @@ -74,6 +84,8 @@ def render(catalog: dict) -> str: " name: string;\n" " price_cents: number;\n" " gov_fee_label?: string;\n" + ' billing_interval?: "month" | "year";\n' + ' allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[];\n' "}\n\n" "export const SERVICE_META: Record = {\n" + "\n".join(lines) diff --git a/scripts/workers/bundle_upsell.py b/scripts/workers/bundle_upsell.py new file mode 100644 index 0000000..e97e6dc --- /dev/null +++ b/scripts/workers/bundle_upsell.py @@ -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 ") + +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'

For NPI ' + f'{npi}.

' + if npi else "" + ) + return f""" + + +
+ + + + +
+

Keep your exclusion screening current

+

Continue monthly OIG/SAM monitoring

+
+

Hi {customer_name},

+

+ Your Provider Compliance Bundle included your first OIG LEIE + and SAM exclusion screening. But exclusion screening is a monthly + obligation under federal guidance — a one-time check doesn't keep you + covered the rest of the year. +

+ {npi_line} + + +
+

Ongoing OIG/SAM Exclusion Monitoring

+

+ 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. +

+

$79 / month — cancel anytime

+
+
+ + Set up monthly monitoring → + +
+

+ Questions? Reply to this email or call (888) 411-0383. +

+
+

Performance West — healthcare compliance.

+
+
+""" + + +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() diff --git a/scripts/workers/job_server.py b/scripts/workers/job_server.py index 8341eed..9b8d035 100644 --- a/scripts/workers/job_server.py +++ b/scripts/workers/job_server.py @@ -1255,6 +1255,14 @@ def handle_process_compliance_service(payload: dict) -> dict: # verification gate and re-dispatches the order for submission). if payload.get("admin_approved"): 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 ent = order.get("entity", {}) diff --git a/scripts/workers/services/npi_provider.py b/scripts/workers/services/npi_provider.py index 656cdcd..761de20 100644 --- a/scripts/workers/services/npi_provider.py +++ b/scripts/workers/services/npi_provider.py @@ -15,8 +15,8 @@ Covers slugs: npi-reactivation reactivate a deactivated NPI nppes-update NPPES data update / attestation medicare-enrollment new Medicare enrollment via PECOS - oig-sam-screening OIG LEIE + SAM exclusion screening (annual) - provider-compliance-bundle revalidation watch + screening + NPPES upkeep + oig-sam-screening OIG LEIE + SAM exclusion screening (monthly) + provider-compliance-bundle revalidation watch + first screening + NPPES upkeep Intake data needed (collected by the npi-intake wizard step): - npi provider's 10-digit NPI @@ -95,12 +95,13 @@ _SLUG_META = { "priority": "high", }, "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/", "action": ( "Run the provider (and any listed staff) against the OIG LEIE and " "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": ( "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/", "action": ( "Onboard the provider into the annual compliance bundle: enroll in " - "revalidation watch, run OIG/SAM screening, and refresh the NPPES " - "record. Set the next revalidation reminder." + "revalidation watch, run the FIRST OIG/SAM screening (included), and " + "refresh the NPPES record. Set the next revalidation reminder, and flag " + "for the $79/month exclusion-monitoring upsell after the first screening." ), "access": ( "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).", }.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 = ( - f"{meta['action']}\n\n" + cycle_note + + f"{meta['action']}\n\n" f"Provider: {provider}\n" f"NPI: {npi}\n" f"PECOS Enrollment ID: {pecos_id or 'not provided'}\n" @@ -220,7 +240,7 @@ class _BaseNPIHandler: self._create_todo( order_number, intake, - title=f"{meta['name']} — {provider} (NPI {npi})", + title=f"{title_prefix}{meta['name']} — {provider} (NPI {npi})", description=description, priority=meta["priority"], ) diff --git a/site/src/components/intake/steps/PaymentStep.astro b/site/src/components/intake/steps/PaymentStep.astro index 2d7b000..ae45576 100644 --- a/site/src/components/intake/steps/PaymentStep.astro +++ b/site/src/components/intake/steps/PaymentStep.astro @@ -7,6 +7,21 @@ import { SERVICE_META, formatUSD, slugVertical } from "../../../lib/intake_manif import CheckoutTrustBand from "../../CheckoutTrustBand.astro"; const meta = SERVICE_META[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; ---
@@ -23,17 +38,17 @@ const vertical = slugVertical(service_slug); Service{meta?.name ?? service_slug}
- Total{meta ? formatUSD(meta.price_cents) : "—"} + {intervalLabel ? "Price" : "Total"} + {meta ? (intervalLabel ? `${formatUSD(meta.price_cents)} / ${intervalLabel}` : formatUSD(meta.price_cents)) : "—"}
+ {intervalLabel && ( +

Billed every {intervalLabel}. Cancel anytime.

+ )}

Click Finish below to continue with the selected method.

@@ -46,6 +61,7 @@ const vertical = slugVertical(service_slug); .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-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-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; } diff --git a/site/src/lib/service-catalog.generated.ts b/site/src/lib/service-catalog.generated.ts index deb5345..0e5fc3e 100644 --- a/site/src/lib/service-catalog.generated.ts +++ b/site/src/lib/service-catalog.generated.ts @@ -12,6 +12,8 @@ export interface ServiceMeta { name: string; price_cents: number; gov_fee_label?: string; + billing_interval?: "month" | "year"; + allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[]; } export const SERVICE_META: Record = { @@ -73,7 +75,7 @@ export const SERVICE_META: Record = { "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)" }, "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)" }, "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 }, diff --git a/site/src/pages/order/oig-sam-screening.astro b/site/src/pages/order/oig-sam-screening.astro index b954677..d3ee3a1 100644 --- a/site/src/pages/order/oig-sam-screening.astro +++ b/site/src/pages/order/oig-sam-screening.astro @@ -9,7 +9,7 @@ const slug = "oig-sam-screening"; const steps = INTAKE_MANIFEST[slug]; const meta = SERVICE_META[slug]; 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."; --- diff --git a/site/src/pages/order/provider-compliance-bundle.astro b/site/src/pages/order/provider-compliance-bundle.astro index 6babaf0..a9acf1d 100644 --- a/site/src/pages/order/provider-compliance-bundle.astro +++ b/site/src/pages/order/provider-compliance-bundle.astro @@ -9,7 +9,7 @@ const slug = "provider-compliance-bundle"; const steps = INTAKE_MANIFEST[slug]; const meta = SERVICE_META[slug]; 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."; --- diff --git a/site/src/pages/services/healthcare/index.astro b/site/src/pages/services/healthcare/index.astro index 68a2164..0dbc935 100644 --- a/site/src/pages/services/healthcare/index.astro +++ b/site/src/pages/services/healthcare/index.astro @@ -40,13 +40,13 @@ const description = "We handle the federal paperwork that keeps providers billab

OIG / SAM Exclusion Screening

-

$299 / year

-

Annual OIG LEIE and SAM exclusion screening for you and your staff, with a compliance certificate.

+

$79 / month

+

Monthly OIG LEIE and SAM exclusion screening for you and your staff, with an audit-ready certificate each cycle. Cancel anytime.

Provider Compliance Bundle

$899 / year

-

Revalidation monitoring, OIG/SAM screening, and NPPES upkeep in one annual package.

+

Revalidation monitoring, NPPES upkeep, and your first OIG/SAM screening in one annual package.

diff --git a/site/src/pages/tools/npi-compliance-check.astro b/site/src/pages/tools/npi-compliance-check.astro index 7bb3e9d..98e929c 100644 --- a/site/src/pages/tools/npi-compliance-check.astro +++ b/site/src/pages/tools/npi-compliance-check.astro @@ -120,7 +120,7 @@ import Base from "../../layouts/Base.astro"; const SERVICE_MAP = { "npi-active": { name: "NPI Reactivation", price: 249, url: "/order/npi-reactivation" }, "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" }, "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) { html += ` ${svc.name} - $${svc.price} → + $${svc.price}${svc.suffix || ""} → `; } html += ``; html += `
- Get the Provider Compliance Bundle ($699/yr) → + Get the Provider Compliance Bundle ($899/yr) → -

Revalidation monitoring, screening, and NPPES upkeep in one package.

+

Revalidation monitoring, NPPES upkeep, and your first OIG/SAM screening in one package.

`; ctaContent.innerHTML = html; } else { ctaContent.innerHTML = `

Looking good — all checks passed

Your NPI and Medicare records appear current. No action is needed right now.

- Add annual OIG/SAM screening — $99/yr + Add monthly OIG/SAM screening — $79/mo `; } }