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>
224 lines
9.7 KiB
Python
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. — 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()
|