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

Performance West

Hi {customer_name},

{urgency.get(reminder_key, urgency["15m"])}

Order

{order_number}

{label}

Complete Payment

If you have questions, reply to this email or visit our support page.

Performance West Inc. — Professional compliance consulting.

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