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>
741 lines
30 KiB
Python
741 lines
30 KiB
Python
"""
|
|
Renewal handler service worker.
|
|
|
|
Manages annual renewals for Canada CRTC (and other) entities:
|
|
- Polls ERPNext Compliance Calendar for upcoming due dates
|
|
- Sends email reminders at 30, 14, 7, and 3 days before due date
|
|
- On renewal date: creates ERPNext Sales Invoice + sends Stripe Checkout link to client
|
|
- Dunning sequence if unpaid after 7, 14 days
|
|
- Admin alert after 14 days unpaid
|
|
|
|
This worker runs as a scheduled task (e.g., cron or systemd timer) rather than
|
|
being triggered by a Sales Order like other service handlers.
|
|
|
|
Environment variables:
|
|
ERPNEXT_URL, ERPNEXT_API_KEY, ERPNEXT_API_SECRET
|
|
API_URL — Express API URL (default: http://api:3001)
|
|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import smtplib
|
|
from datetime import datetime, timedelta
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from typing import Any
|
|
|
|
LOG = logging.getLogger("workers.renewal_handler")
|
|
|
|
# Stripe secret is no longer used directly in this module (payment is via Express API
|
|
# checkout flow + Stripe webhook). Kept here in case it's needed for future
|
|
# off-session charging once saved payment methods are implemented.
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Configuration
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com")
|
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
|
SMTP_FROM = os.getenv("SMTP_FROM", "orders@performancewest.net")
|
|
|
|
ADMIN_EMAIL = "ops@performancewest.net"
|
|
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
|
|
|
# Reminder schedule: days before due date
|
|
REMINDER_DAYS = [30, 14, 7, 3]
|
|
|
|
# Dunning schedule: days after failed payment
|
|
DUNNING_DAYS = [0, 7, 14]
|
|
|
|
|
|
class RenewalHandler:
|
|
"""Handles annual compliance renewals for all managed entities.
|
|
|
|
Designed to run on a daily schedule (e.g., cron job or systemd timer).
|
|
Each run:
|
|
1. Queries ERPNext Compliance Calendar for entries due within window
|
|
2. Sends appropriate reminders or processes renewals
|
|
3. Handles payment failures with dunning sequence
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._erp = None
|
|
self._stripe = None
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# ERPNext client
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@property
|
|
def erp(self):
|
|
if self._erp is None:
|
|
from scripts.workers.erpnext_client import ERPNextClient
|
|
self._erp = ERPNextClient()
|
|
return self._erp
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Stripe client
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@property
|
|
def stripe(self):
|
|
if self._stripe is None:
|
|
import stripe
|
|
stripe.api_key = STRIPE_SECRET_KEY
|
|
self._stripe = stripe
|
|
return self._stripe
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Main entry point — run daily
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def run(self) -> dict[str, int]:
|
|
"""Execute a single renewal check cycle.
|
|
|
|
Returns:
|
|
Summary dict with counts: reminders_sent, renewals_processed,
|
|
payments_failed, admin_alerts.
|
|
"""
|
|
LOG.info("=== Renewal handler cycle START ===")
|
|
today = datetime.utcnow().date()
|
|
|
|
summary = {
|
|
"reminders_sent": 0,
|
|
"renewals_processed": 0,
|
|
"payments_failed": 0,
|
|
"admin_alerts": 0,
|
|
}
|
|
|
|
# -------------------------------------------------------------- #
|
|
# Phase 1: Send upcoming reminders
|
|
# -------------------------------------------------------------- #
|
|
for days_before in REMINDER_DAYS:
|
|
target_date = (today + timedelta(days=days_before)).strftime("%Y-%m-%d")
|
|
entries = self._get_compliance_entries(
|
|
due_date=target_date,
|
|
statuses=["Upcoming", "Reminder Sent"],
|
|
)
|
|
for entry in entries:
|
|
self._send_reminder(entry, days_before)
|
|
self._update_entry_status(entry["name"], "Reminder Sent")
|
|
summary["reminders_sent"] += 1
|
|
|
|
# -------------------------------------------------------------- #
|
|
# Phase 2: Process renewals due today
|
|
# -------------------------------------------------------------- #
|
|
due_today = self._get_compliance_entries(
|
|
due_date=today.strftime("%Y-%m-%d"),
|
|
statuses=["Upcoming", "Reminder Sent", "Due"],
|
|
)
|
|
for entry in due_today:
|
|
self._update_entry_status(entry["name"], "Processing")
|
|
|
|
success = self._process_renewal(entry)
|
|
if success:
|
|
self._update_entry_status(entry["name"], "Completed")
|
|
self._create_next_recurrence(entry)
|
|
summary["renewals_processed"] += 1
|
|
else:
|
|
self._update_entry_status(entry["name"], "Payment Failed")
|
|
self._send_dunning_email(entry, days_overdue=0)
|
|
summary["payments_failed"] += 1
|
|
|
|
# -------------------------------------------------------------- #
|
|
# Phase 3: Dunning for previously failed payments
|
|
# -------------------------------------------------------------- #
|
|
for days_overdue in DUNNING_DAYS[1:]: # Skip 0 (handled above)
|
|
overdue_date = (today - timedelta(days=days_overdue)).strftime("%Y-%m-%d")
|
|
failed_entries = self._get_compliance_entries(
|
|
due_date=overdue_date,
|
|
statuses=["Payment Failed", "Overdue"],
|
|
)
|
|
for entry in failed_entries:
|
|
if days_overdue >= 14:
|
|
# Final dunning — admin alert
|
|
self._send_admin_alert(entry, days_overdue)
|
|
self._update_entry_status(entry["name"], "Admin Alert")
|
|
summary["admin_alerts"] += 1
|
|
else:
|
|
# Retry payment + send dunning email
|
|
success = self._retry_payment(entry)
|
|
if success:
|
|
self._process_renewal_actions(entry)
|
|
self._update_entry_status(entry["name"], "Completed")
|
|
self._create_next_recurrence(entry)
|
|
summary["renewals_processed"] += 1
|
|
else:
|
|
self._send_dunning_email(entry, days_overdue)
|
|
self._update_entry_status(entry["name"], "Overdue")
|
|
summary["payments_failed"] += 1
|
|
|
|
LOG.info(
|
|
"=== Renewal handler cycle COMPLETE: %d reminders, %d renewals, %d failed, %d alerts ===",
|
|
summary["reminders_sent"],
|
|
summary["renewals_processed"],
|
|
summary["payments_failed"],
|
|
summary["admin_alerts"],
|
|
)
|
|
return summary
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# ERPNext Compliance Calendar queries
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _get_compliance_entries(
|
|
self,
|
|
due_date: str,
|
|
statuses: list[str],
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch Compliance Calendar entries from ERPNext for a given date and status."""
|
|
try:
|
|
entries = self.erp.get_resource(
|
|
"Compliance Calendar",
|
|
filters={
|
|
"due_date": due_date,
|
|
"status": ["in", statuses],
|
|
},
|
|
fields=[
|
|
"name", "entity_name", "order_reference", "compliance_type",
|
|
"description", "due_date", "amount_cad", "recurring",
|
|
"recurrence_period", "status",
|
|
],
|
|
limit=100,
|
|
)
|
|
if isinstance(entries, dict):
|
|
entries = [entries]
|
|
return entries
|
|
except Exception as exc:
|
|
LOG.error("Failed to query Compliance Calendar for %s: %s", due_date, exc)
|
|
return []
|
|
|
|
def _update_entry_status(self, entry_name: str, status: str) -> None:
|
|
"""Update a Compliance Calendar entry's status in ERPNext."""
|
|
try:
|
|
self.erp.update_resource("Compliance Calendar", entry_name, {
|
|
"status": status,
|
|
})
|
|
LOG.info("Compliance entry %s → %s", entry_name, status)
|
|
except Exception as exc:
|
|
LOG.error("Failed to update compliance entry %s to %s: %s", entry_name, status, exc)
|
|
|
|
def _create_next_recurrence(self, entry: dict) -> None:
|
|
"""Create the next year's compliance entry if recurring."""
|
|
if not entry.get("recurring"):
|
|
return
|
|
|
|
try:
|
|
current_due = datetime.strptime(entry["due_date"], "%Y-%m-%d")
|
|
period = entry.get("recurrence_period", "Yearly")
|
|
|
|
if period == "Yearly":
|
|
next_due = current_due + timedelta(days=365)
|
|
elif period == "Monthly":
|
|
next_due = current_due + timedelta(days=30)
|
|
elif period == "Quarterly":
|
|
next_due = current_due + timedelta(days=91)
|
|
else:
|
|
next_due = current_due + timedelta(days=365)
|
|
|
|
new_entry = {
|
|
"doctype": "Compliance Calendar",
|
|
"entity_name": entry["entity_name"],
|
|
"order_reference": entry["order_reference"],
|
|
"compliance_type": entry["compliance_type"],
|
|
"description": entry["description"],
|
|
"due_date": next_due.strftime("%Y-%m-%d"),
|
|
"amount_cad": entry.get("amount_cad", 0),
|
|
"recurring": 1,
|
|
"recurrence_period": period,
|
|
"status": "Upcoming",
|
|
}
|
|
self.erp.create_resource("Compliance Calendar", new_entry)
|
|
LOG.info(
|
|
"Created next recurrence for %s (%s): due %s",
|
|
entry["entity_name"],
|
|
entry["compliance_type"],
|
|
next_due.strftime("%Y-%m-%d"),
|
|
)
|
|
except Exception as exc:
|
|
LOG.error("Failed to create next recurrence for %s: %s", entry["name"], exc)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Renewal processing
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _process_renewal(self, entry: dict) -> bool:
|
|
"""Process a renewal: charge payment, then execute renewal actions.
|
|
|
|
Returns True if payment succeeded.
|
|
"""
|
|
compliance_type = entry.get("compliance_type", "")
|
|
entity_name = entry.get("entity_name", "")
|
|
amount_cad = entry.get("amount_cad", 0)
|
|
|
|
LOG.info("Processing renewal: %s for %s (C$%.2f)", compliance_type, entity_name, amount_cad)
|
|
|
|
# Step 1: Create payment invoice + send link (async — customer pays via Stripe)
|
|
if amount_cad > 0:
|
|
payment_success = self._charge_payment(entry)
|
|
if not payment_success:
|
|
return False
|
|
|
|
# Step 2: Execute type-specific renewal actions
|
|
self._process_renewal_actions(entry)
|
|
return True
|
|
|
|
def _process_renewal_actions(self, entry: dict) -> None:
|
|
"""Execute the actual renewal actions (mailbox, annual report, CRTC check).
|
|
|
|
Dispatches based on compliance_type.
|
|
"""
|
|
compliance_type = entry.get("compliance_type", "")
|
|
entity_name = entry.get("entity_name", "")
|
|
|
|
if compliance_type == "Mailbox Renewal":
|
|
self._renew_mailbox(entry)
|
|
elif compliance_type == "BC Annual Report":
|
|
self._file_annual_report(entry)
|
|
elif compliance_type == "CRTC Compliance Check":
|
|
self._crtc_compliance_check(entry)
|
|
else:
|
|
LOG.warning("Unknown compliance type: %s for %s", compliance_type, entity_name)
|
|
|
|
def _renew_mailbox(self, entry: dict) -> bool:
|
|
"""Renew Anytime Mailbox via Playwright automation.
|
|
|
|
Logs into the Anytime Mailbox dashboard and processes the renewal.
|
|
Stub — requires portal selectors.
|
|
"""
|
|
entity_name = entry.get("entity_name", "")
|
|
LOG.info("Renewing Anytime Mailbox for %s", entity_name)
|
|
|
|
try:
|
|
import asyncio
|
|
from scripts.formation.states.bc.adapter import BCPortal
|
|
from scripts.formation.base import FormationOrder, EntityType
|
|
|
|
portal = BCPortal()
|
|
|
|
# Build minimal order for mailbox renewal
|
|
order = FormationOrder(
|
|
order_id=entry.get("order_reference", ""),
|
|
state_code="BC",
|
|
entity_type=EntityType.CORPORATION,
|
|
entity_name=entity_name,
|
|
)
|
|
|
|
# TODO: Implement actual mailbox renewal automation
|
|
# For now this is a stub — Anytime Mailbox may auto-renew
|
|
# if payment method is on file. If not, we need Playwright
|
|
# automation to log in and process renewal.
|
|
LOG.warning("Mailbox renewal automation not yet implemented — may auto-renew")
|
|
return True
|
|
|
|
except Exception as exc:
|
|
LOG.error("Mailbox renewal failed for %s: %s", entity_name, exc)
|
|
return False
|
|
|
|
def _file_annual_report(self, entry: dict) -> bool:
|
|
"""File BC Annual Report via Corporate Online (Playwright).
|
|
|
|
BC Annual Reports confirm registered office address and director info.
|
|
Cost: C$42. Must be filed within 2 months of anniversary.
|
|
Stub — requires Corporate Online selectors.
|
|
"""
|
|
entity_name = entry.get("entity_name", "")
|
|
LOG.info("Filing BC Annual Report for %s", entity_name)
|
|
|
|
try:
|
|
import asyncio
|
|
from scripts.formation.states.bc.adapter import BCPortal
|
|
from scripts.formation.states.bc.config import CONFIG as BC_CONFIG
|
|
|
|
portal = BCPortal()
|
|
|
|
# TODO: Implement Playwright automation for BC Annual Report:
|
|
# 1. Login to Corporate Online
|
|
# 2. Navigate to Annual Report filing
|
|
# 3. Confirm registered office address
|
|
# 4. Confirm director information
|
|
# 5. Pay C$42 via Relay card
|
|
# 6. Capture confirmation number
|
|
#
|
|
# Selectors in BC_CONFIG["selectors"]:
|
|
# ar_filing_year, ar_confirm_address, ar_submit
|
|
|
|
LOG.warning("BC Annual Report automation not yet implemented — requires portal selectors")
|
|
return True
|
|
|
|
except Exception as exc:
|
|
LOG.error("BC Annual Report filing failed for %s: %s", entity_name, exc)
|
|
return False
|
|
|
|
def _crtc_compliance_check(self, entry: dict) -> bool:
|
|
"""Perform annual CRTC compliance check.
|
|
|
|
Verifies that the CRTC registration is still current and checks
|
|
for any regulatory changes that may affect the entity.
|
|
"""
|
|
entity_name = entry.get("entity_name", "")
|
|
LOG.info("Performing CRTC compliance check for %s", entity_name)
|
|
|
|
# CRTC compliance check is currently a manual process:
|
|
# 1. Verify entity is still listed in CRTC registry
|
|
# 2. Check for new CRTC regulations affecting the entity
|
|
# 3. Verify contact information is current
|
|
#
|
|
# For now, create a task for manual review.
|
|
try:
|
|
self.erp.create_resource("Issue", {
|
|
"subject": f"CRTC Annual Compliance Check: {entity_name}",
|
|
"description": (
|
|
f"Annual CRTC compliance check due for **{entity_name}**.\n\n"
|
|
f"Manual steps:\n"
|
|
f"1. Verify entity is still listed in CRTC registry\n"
|
|
f"2. Check for new CRTC regulations\n"
|
|
f"3. Verify contact information is current\n"
|
|
f"4. File any required updates\n\n"
|
|
f"Order reference: {entry.get('order_reference', 'N/A')}"
|
|
),
|
|
"priority": "Medium",
|
|
"issue_type": "Feature Request",
|
|
})
|
|
LOG.info("Created CRTC compliance check task for %s", entity_name)
|
|
return True
|
|
except Exception as exc:
|
|
LOG.error("Failed to create CRTC compliance check task: %s", exc)
|
|
return False
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Payment — ERPNext Sales Invoice + Stripe Checkout Session
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _charge_payment(self, entry: dict) -> bool:
|
|
"""Create an ERPNext Sales Invoice + Stripe Checkout Session for the renewal.
|
|
|
|
Sends a payment link to the client. Returns True immediately (payment
|
|
is async — the client pays via the emailed link). The Stripe webhook
|
|
will confirm payment and trigger renewal actions.
|
|
|
|
Falls back to creating an admin ToDo if ERPNext is unavailable.
|
|
"""
|
|
entity_name = entry.get("entity_name", "")
|
|
amount_cad = entry.get("amount_cad", 0)
|
|
order_ref = entry.get("order_reference", "")
|
|
compliance_type = entry.get("compliance_type", "Annual Renewal")
|
|
|
|
if amount_cad <= 0:
|
|
LOG.info("No charge needed for %s (%s)", entity_name, compliance_type)
|
|
return True
|
|
|
|
LOG.info("Creating renewal invoice C$%.2f for %s (%s)", amount_cad, entity_name, compliance_type)
|
|
|
|
# Look up customer info from the original Sales Order
|
|
client_email = ""
|
|
erpnext_customer = ""
|
|
try:
|
|
order_data = self.erp.get_resource("Sales Order", order_ref)
|
|
if isinstance(order_data, list):
|
|
order_data = order_data[0] if order_data else {}
|
|
client_email = order_data.get("customer_email", "") or order_data.get("custom_customer_email", "")
|
|
erpnext_customer = order_data.get("customer", "")
|
|
except Exception as exc:
|
|
LOG.warning("Could not fetch order data for %s: %s — using admin fallback", order_ref, exc)
|
|
|
|
if not erpnext_customer:
|
|
# Fall back to admin ToDo
|
|
LOG.warning("No ERPNext customer found for %s — creating admin task", order_ref)
|
|
try:
|
|
self.erp.create_resource("ToDo", {
|
|
"description": (
|
|
f"MANUAL RENEWAL REQUIRED\n"
|
|
f" Entity: {entity_name}\n"
|
|
f" Order: {order_ref}\n"
|
|
f" Type: {compliance_type}\n"
|
|
f" Amount: C${amount_cad:.2f}\n\n"
|
|
f"Create invoice and send payment link manually."
|
|
),
|
|
"assigned_by": "Administrator",
|
|
})
|
|
except Exception as td_err:
|
|
LOG.error("Failed to create admin ToDo for %s: %s", order_ref, td_err)
|
|
return True # Non-blocking — admin handles it
|
|
|
|
# Create ERPNext Sales Invoice
|
|
try:
|
|
invoice = self.erp.create_resource("Sales Invoice", {
|
|
"customer": erpnext_customer,
|
|
"due_date": (datetime.utcnow() + timedelta(days=14)).strftime("%Y-%m-%d"),
|
|
"custom_external_order_id": f"{order_ref}-renewal-{datetime.utcnow().strftime('%Y%m')}",
|
|
"custom_order_type": "renewal",
|
|
"items": [{
|
|
"item_code": compliance_type,
|
|
"qty": 1,
|
|
"rate": amount_cad,
|
|
"description": f"Annual renewal — {entity_name}",
|
|
}],
|
|
})
|
|
invoice_name = invoice.get("name", "")
|
|
|
|
self.erp.call_method("frappe.client.submit", {
|
|
"doc": {"doctype": "Sales Invoice", "name": invoice_name}
|
|
})
|
|
|
|
# Create Stripe Payment Request via Express API
|
|
import urllib.request, json as _json
|
|
api_url = os.getenv("API_URL", "http://api:3001")
|
|
payload = _json.dumps({
|
|
"order_id": f"{order_ref}-renewal",
|
|
"order_type": "compliance",
|
|
"payment_method": "card",
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{api_url}/api/v1/checkout/create-session",
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
result = _json.loads(resp.read())
|
|
|
|
checkout_url = result.get("checkout_url", "")
|
|
if checkout_url and client_email:
|
|
# Send payment link email
|
|
self._send_email(
|
|
to_email=client_email,
|
|
subject=f"Renewal payment required — {entity_name}",
|
|
body=(
|
|
f"Your annual {compliance_type} renewal for {entity_name} "
|
|
f"is due. Amount: C${amount_cad:.2f}.\n\n"
|
|
f"Pay here: {checkout_url}\n\n"
|
|
f"This link expires in 7 days. Reply to this email if you "
|
|
f"have questions.\n\nPerformance West Inc."
|
|
),
|
|
)
|
|
LOG.info("Renewal payment link sent to %s for %s", client_email, entity_name)
|
|
|
|
self.erp.update_resource("Compliance Calendar", entry["name"], {
|
|
"custom_invoice_name": invoice_name,
|
|
"custom_payment_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
|
})
|
|
return True # Payment link sent; actual confirmation via Stripe webhook
|
|
|
|
except Exception as exc:
|
|
LOG.error("Renewal payment setup failed for %s: %s", entity_name, exc)
|
|
return False
|
|
|
|
def _retry_payment(self, entry: dict) -> bool:
|
|
"""Retry a failed payment by re-sending the payment link."""
|
|
LOG.info("Retrying payment for %s", entry.get("entity_name", ""))
|
|
return self._charge_payment(entry)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Email: reminders & dunning
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _send_reminder(self, entry: dict, days_before: int) -> None:
|
|
"""Send a renewal reminder email to the client."""
|
|
entity_name = entry.get("entity_name", "")
|
|
compliance_type = entry.get("compliance_type", "Renewal")
|
|
due_date = entry.get("due_date", "")
|
|
amount_cad = entry.get("amount_cad", 0)
|
|
order_ref = entry.get("order_reference", "")
|
|
|
|
# Look up client email from ERPNext order
|
|
client_email = self._get_client_email(order_ref)
|
|
if not client_email:
|
|
LOG.warning("No client email found for order %s — skipping reminder", order_ref)
|
|
return
|
|
|
|
subject = f"Upcoming {compliance_type} — {entity_name} — Due in {days_before} days"
|
|
|
|
body = (
|
|
f"Dear Client,\n\n"
|
|
f"This is a reminder that the following compliance obligation "
|
|
f"is coming due:\n\n"
|
|
f" Entity: {entity_name}\n"
|
|
f" Type: {compliance_type}\n"
|
|
f" Due Date: {due_date}\n"
|
|
)
|
|
|
|
if amount_cad > 0:
|
|
body += f" Amount: C${amount_cad:.2f}\n"
|
|
|
|
body += (
|
|
f"\n"
|
|
f"Your payment method on file will be charged automatically on "
|
|
f"the due date. If you need to update your payment method, "
|
|
f"please contact us before then.\n\n"
|
|
f"If you have any questions, please reply to this email.\n\n"
|
|
f"Best regards,\n"
|
|
f"Performance West Inc.\n"
|
|
)
|
|
|
|
self._send_email(to_email=client_email, subject=subject, body=body)
|
|
LOG.info("Sent %d-day reminder to %s for %s (%s)", days_before, client_email, entity_name, compliance_type)
|
|
|
|
def _send_dunning_email(self, entry: dict, days_overdue: int) -> None:
|
|
"""Send a payment failure / dunning email to the client."""
|
|
entity_name = entry.get("entity_name", "")
|
|
compliance_type = entry.get("compliance_type", "Renewal")
|
|
amount_cad = entry.get("amount_cad", 0)
|
|
order_ref = entry.get("order_reference", "")
|
|
|
|
client_email = self._get_client_email(order_ref)
|
|
if not client_email:
|
|
LOG.warning("No client email found for order %s — skipping dunning email", order_ref)
|
|
return
|
|
|
|
if days_overdue == 0:
|
|
urgency = "Payment Failed"
|
|
deadline_note = "Please update your payment method within 7 days to avoid service interruption."
|
|
elif days_overdue <= 7:
|
|
urgency = "Payment Past Due"
|
|
deadline_note = (
|
|
"Your payment is 7 days past due. Please update your payment "
|
|
"method immediately to maintain your compliance status."
|
|
)
|
|
else:
|
|
urgency = "URGENT: Payment Overdue"
|
|
deadline_note = (
|
|
"Your payment is 14 days overdue. Your compliance obligations "
|
|
"may lapse if payment is not received. This is our final notice "
|
|
"before escalation to an administrator."
|
|
)
|
|
|
|
subject = f"[{urgency}] {compliance_type} — {entity_name}"
|
|
|
|
body = (
|
|
f"Dear Client,\n\n"
|
|
f"We were unable to process your payment for the following "
|
|
f"compliance obligation:\n\n"
|
|
f" Entity: {entity_name}\n"
|
|
f" Type: {compliance_type}\n"
|
|
f" Amount: C${amount_cad:.2f}\n\n"
|
|
f"{deadline_note}\n\n"
|
|
f"To update your payment method, please reply to this email "
|
|
f"or contact us at {ADMIN_EMAIL}.\n\n"
|
|
f"Best regards,\n"
|
|
f"Performance West Inc.\n"
|
|
)
|
|
|
|
self._send_email(to_email=client_email, subject=subject, body=body)
|
|
LOG.info(
|
|
"Sent dunning email (%d days overdue) to %s for %s",
|
|
days_overdue, client_email, entity_name,
|
|
)
|
|
|
|
def _send_admin_alert(self, entry: dict, days_overdue: int) -> None:
|
|
"""Send admin alert after dunning sequence is exhausted."""
|
|
entity_name = entry.get("entity_name", "")
|
|
compliance_type = entry.get("compliance_type", "")
|
|
order_ref = entry.get("order_reference", "")
|
|
amount_cad = entry.get("amount_cad", 0)
|
|
|
|
subject = (
|
|
f"[ADMIN ALERT] Unpaid Renewal: {entity_name} — "
|
|
f"{compliance_type} — {days_overdue} days overdue"
|
|
)
|
|
|
|
body = (
|
|
f"The following compliance renewal is {days_overdue} days overdue "
|
|
f"after exhausting the automated dunning sequence:\n\n"
|
|
f" Entity: {entity_name}\n"
|
|
f" Type: {compliance_type}\n"
|
|
f" Amount: C${amount_cad:.2f}\n"
|
|
f" Order: {order_ref}\n"
|
|
f" Days Overdue: {days_overdue}\n\n"
|
|
f"Manual intervention required:\n"
|
|
f" 1. Contact client directly\n"
|
|
f" 2. Decide whether to process renewal anyway or cancel\n"
|
|
f" 3. Update compliance entry status in ERPNext\n"
|
|
)
|
|
|
|
self._send_email(to_email=ADMIN_EMAIL, subject=subject, body=body)
|
|
|
|
# Also create an ERPNext Issue for tracking
|
|
try:
|
|
self.erp.create_resource("Issue", {
|
|
"subject": subject,
|
|
"description": body,
|
|
"priority": "High",
|
|
"issue_type": "Bug",
|
|
})
|
|
except Exception as exc:
|
|
LOG.error("Failed to create admin alert issue: %s", exc)
|
|
|
|
LOG.info("Admin alert sent for %s (%s) — %d days overdue", entity_name, compliance_type, days_overdue)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Helpers
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _get_client_email(self, order_ref: str) -> str:
|
|
"""Look up the client's email from the ERPNext order."""
|
|
if not order_ref:
|
|
return ""
|
|
try:
|
|
order_data = self.erp.get_resource("Sales Order", order_ref)
|
|
if isinstance(order_data, list):
|
|
if not order_data:
|
|
return ""
|
|
order_data = order_data[0]
|
|
return (
|
|
order_data.get("custom_client_email")
|
|
or order_data.get("customer_email")
|
|
or ""
|
|
)
|
|
except Exception as exc:
|
|
LOG.error("Failed to look up client email for %s: %s", order_ref, exc)
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _send_email(to_email: str, subject: str, body: str) -> None:
|
|
"""Send a plain-text email via SMTP."""
|
|
if not SMTP_USER or not SMTP_PASSWORD:
|
|
LOG.warning("SMTP not configured — skipping email to %s", to_email)
|
|
return
|
|
|
|
msg = MIMEMultipart()
|
|
msg["From"] = SMTP_FROM
|
|
msg["To"] = to_email
|
|
msg["Subject"] = subject
|
|
msg.attach(MIMEText(body, "plain"))
|
|
|
|
try:
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
server.starttls()
|
|
server.login(SMTP_USER, SMTP_PASSWORD)
|
|
server.send_message(msg)
|
|
LOG.info("Email sent to %s: %s", to_email, subject)
|
|
except Exception as exc:
|
|
LOG.error("Failed to send email to %s: %s", to_email, exc)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# CLI entry point — run one cycle
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def main() -> None:
|
|
"""Run a single renewal check cycle (for cron or manual execution)."""
|
|
import logging
|
|
import sys
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
handlers=[logging.StreamHandler(sys.stdout)],
|
|
)
|
|
|
|
handler = RenewalHandler()
|
|
summary = handler.run()
|
|
print(f"Renewal cycle complete: {summary}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|