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