new-site/scripts/workers/payment_reminder.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

224 lines
9.7 KiB
Python

"""
Payment reminder worker — sends abandoned cart recovery emails.
Schedule: runs every 5 minutes via cron or systemd timer.
- 15-minute reminder: order placed but not paid within 15 minutes
- 1-day reminder: still unpaid after 24 hours
- 2-day reminder: still unpaid after 48 hours
Each reminder is sent at most once per order (tracked by reminder_*_sent_at columns).
"""
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.payment_reminder")
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>")
# Reminder intervals (from order creation time)
REMINDERS = [
{"key": "15m", "column": "reminder_15m_sent_at", "after": timedelta(minutes=15), "before": timedelta(hours=2), "subject": "Complete your order — {order_number}"},
{"key": "1d", "column": "reminder_1d_sent_at", "after": timedelta(days=1), "before": timedelta(days=2), "subject": "Your order is waiting — {order_number}"},
{"key": "2d", "column": "reminder_2d_sent_at", "after": timedelta(days=2), "before": timedelta(days=7), "subject": "Last reminder — complete your order {order_number}"},
]
ORDER_TABLES = [
("canada_crtc_orders", "canada_crtc"),
("formation_orders", "formation"),
("bundle_orders", "bundle"),
]
ORDER_TYPE_LABELS = {
"canada_crtc": "Canada CRTC Carrier Package",
"formation": "Business Formation",
"bundle": "Compliance Bundle",
}
def build_email_html(
customer_name: str,
order_number: str,
order_type: str,
reminder_key: str,
checkout_url: str,
) -> str:
"""Build branded HTML reminder email."""
label = ORDER_TYPE_LABELS.get(order_type, "Order")
urgency = {
"15m": "You started your order but haven't completed payment yet. Your order details are saved — just click below to pick up where you left off.",
"1d": "We noticed your order from yesterday is still waiting for payment. Everything is saved and ready to go — complete checkout in under a minute.",
"2d": "This is a final reminder that your order has not been paid. If you're having trouble with payment, please reply to this email or contact our support team and we'll help you out.",
}
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:#f7f7f7;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f7;padding:32px 16px;">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#fff;border-radius:12px;border:1px solid #e5e5e5;overflow:hidden;">
<tr><td style="background:#1e3a5f;padding:24px 32px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:20px;font-weight:700;">Performance West</h1>
</td></tr>
<tr><td style="padding:32px;">
<p style="margin:0 0 16px;font-size:15px;color:#333;">Hi {customer_name},</p>
<p style="margin:0 0 20px;font-size:14px;color:#555;line-height:1.6;">{urgency.get(reminder_key, urgency["15m"])}</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;margin:0 0 24px;">
<tr><td style="padding:16px;">
<p style="margin:0 0 4px;font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Order</p>
<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#111;font-family:monospace;">{order_number}</p>
<p style="margin:0;font-size:13px;color:#555;">{label}</p>
</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
<a href="{checkout_url}" style="display:inline-block;background:#1e3a5f;color:#fff;font-size:15px;font-weight:600;padding:14px 32px;border-radius:8px;text-decoration:none;">
Complete Payment
</a>
</td></tr></table>
<p style="margin:24px 0 0;font-size:12px;color:#9ca3af;text-align:center;">
If you have questions, reply to this email or visit
<a href="https://{DOMAIN}/contact" style="color:#1e3a5f;">our support page</a>.
</p>
</td></tr>
<tr><td style="padding:16px 32px;background:#f9fafb;border-top:1px solid #e5e7eb;text-align:center;">
<p style="margin:0;font-size:11px;color:#9ca3af;">Performance West Inc. &mdash; Professional compliance consulting.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def build_email_text(
customer_name: str,
order_number: str,
order_type: str,
reminder_key: str,
checkout_url: str,
) -> str:
label = ORDER_TYPE_LABELS.get(order_type, "Order")
return (
f"Hi {customer_name},\n\n"
f"Your order {order_number} ({label}) has not been paid yet.\n\n"
f"Complete payment here: {checkout_url}\n\n"
f"If you need help, reply to this email or visit https://{DOMAIN}/contact\n\n"
f"Performance West Inc.\n"
)
def send_reminder_email(
to_email: str,
customer_name: str,
order_number: str,
order_type: str,
reminder_key: str,
) -> bool:
"""Send a payment reminder email. Returns True on success."""
checkout_url = f"https://{DOMAIN}/order/cancelled?order_id={order_number}&order_type={order_type}"
subject = {
"15m": f"Complete your order — {order_number}",
"1d": f"Your order is waiting — {order_number}",
"2d": f"Last reminder — complete your order {order_number}",
}.get(reminder_key, f"Complete your order — {order_number}")
msg = MIMEMultipart("alternative")
msg["From"] = SMTP_FROM
msg["To"] = to_email
msg["Subject"] = subject
msg["Reply-To"] = f"support@{DOMAIN}"
msg.attach(MIMEText(build_email_text(customer_name, order_number, order_type, reminder_key, checkout_url), "plain"))
msg.attach(MIMEText(build_email_html(customer_name, order_number, order_type, reminder_key, checkout_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 %s reminder to %s for %s", reminder_key, to_email, order_number)
return True
except Exception as e:
LOG.error("Failed to send %s reminder to %s: %s", reminder_key, to_email, e)
return False
def process_reminders():
"""Check all order tables for unpaid orders and send due reminders."""
now = datetime.now(timezone.utc)
conn = psycopg2.connect(DATABASE_URL)
sent_count = 0
try:
for table, order_type in ORDER_TABLES:
for reminder in REMINDERS:
key = reminder["key"]
col = reminder["column"]
after = reminder["after"]
before = reminder["before"]
# Find orders that:
# - are still pending_payment
# - were created between [after] and [before] ago
# - haven't had this reminder sent yet
window_start = now - before
window_end = now - after
with conn.cursor() as cur:
# Find unpaid orders in the reminder window, but exclude any
# where the same customer has placed a newer order (any status).
# This prevents nagging about an abandoned order when they've
# already started a fresh one.
cur.execute(f"""
SELECT o.order_number, o.customer_email, o.customer_name
FROM {table} o
WHERE o.payment_status = 'pending_payment'
AND o.created_at BETWEEN %s AND %s
AND o.{col} IS NULL
AND NOT EXISTS (
SELECT 1 FROM {table} newer
WHERE newer.customer_email = o.customer_email
AND newer.created_at > o.created_at
)
LIMIT 50
""", (window_start, window_end))
rows = cur.fetchall()
for order_number, email, name in rows:
if not email:
continue
ok = send_reminder_email(email, name or "there", order_number, order_type, key)
if ok:
cur.execute(
f"UPDATE {table} SET {col} = %s WHERE order_number = %s",
(now, order_number),
)
conn.commit()
sent_count += 1
LOG.info("Payment reminder run complete: %d emails sent", sent_count)
except Exception as e:
LOG.error("Payment reminder error: %s", e)
conn.rollback()
finally:
conn.close()
if __name__ == "__main__":
process_reminders()