feat(healthcare): OIG/SAM exclusion screening as $79/mo Stripe Subscription

Convert OIG/SAM from one-time $299/yr to recurring $79/month (card+ACH only) -
the first real recurring-billing product in the system. Exclusion screening is
a *monthly* federal obligation, so recurring monitoring fits the requirement and
is the biggest valuation lever (vs a one-time annual run).

Catalog (single source of truth):
- service-catalog.ts: add billing_interval + allowed_methods to ComplianceService;
  oig-sam-screening -> 7900c, billing_interval:"month", allowed_methods:[card,ach],
  name "(Monthly Monitoring)".
- gen-service-catalog.py + check-service-catalog-drift.py: carry/guard the two new
  fields; regenerate site catalog.

Checkout (api/src/routes/checkout.ts):
- mode:"subscription" with recurring price_data when billing_interval is set;
  surcharge absorbed for recurring (clean $79/mo); server-side METHOD_NOT_ALLOWED
  re-validation against allowed_methods.
- ensureColumns + migration 100: compliance_orders.stripe_subscription_id,
  bundle_upsell_sent_at (+ subscription index).

Webhooks (api/src/routes/webhooks.ts):
- record stripe_subscription_id on checkout.session.completed (subscription mode).
- invoice.paid (subscription_cycle only) -> re-dispatch screening for the cycle;
  invoice.payment_failed -> admin alert + first-failure customer nudge;
  customer.subscription.deleted -> mark order cancelled. (API 2026-03-25 moved the
  subscription link to invoice.parent.subscription_details.subscription.)

Fulfillment:
- job_server.py: pass recurring_cycle/invoice_id into the order.
- npi_provider.py: OIG handler labels renewal cycles "[Monthly cycle]" + re-screen
  note; bundle action runs only the FIRST screening + flags the $79/mo upsell.

Bundle land-and-expand:
- Provider Compliance Bundle now includes only the first OIG/SAM screening (was
  giving away $948/yr of monitoring inside an $899 bundle).
- new worker scripts/workers/bundle_upsell.py (+ pw-bundle-upsell timer): ~3 weeks
  after a paid bundle, emails the customer to continue $79/mo monitoring; dedup via
  bundle_upsell_sent_at; skips customers who already have an OIG/SAM order.

Surfaces updated to $79/mo: PaymentStep (filters methods, "Billed every month,
cancel anytime"), order pages, healthcare index, npi-compliance-check tool (also
fixed stale $699 bundle drift -> $899), hc_oig_screening + hc_compliance_bundle
emails.

Docs: billing.md gains a "Stripe-native Subscriptions" section + a reality-check
banner (Adyen/ERPNext-gateway model documented there is NOT live; Stripe is the
real rail). Fixed run-migrations.yml container name bug
(performancewest-postgres-1 -> performancewest-api-postgres-1, overridable).

Tests: api/tests/recurring-subscription.test.ts (28 assertions) covers catalog
gating, method validation, surcharge suppression, recurring line-item build,
invoiceSubscriptionId extraction, renewal-cycle gating. tsc clean; site build
clean; catalog drift OK.

Manual deploy step: enable invoice.paid, invoice.payment_failed,
customer.subscription.deleted on the Stripe webhook endpoint.
This commit is contained in:
justin 2026-06-18 07:54:38 -05:00
parent f481a1d13c
commit cf021e2f91
21 changed files with 820 additions and 69 deletions

View file

@ -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")

View file

@ -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<string, ServiceMeta> = {\n"
+ "\n".join(lines)

View file

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

View file

@ -1255,6 +1255,14 @@ def handle_process_compliance_service(payload: dict) -> dict:
# verification gate and re-dispatches the order for submission).
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", {})

View file

@ -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"],
)