new-site/scripts/workers/bundle_upsell.py
justin cf021e2f91 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.
2026-06-18 07:54:38 -05:00

206 lines
9.2 KiB
Python

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