new-site/scripts/workers/services/renewal_handler.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

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