#!/usr/bin/env python3 """Compliance Calendar Renewal Worker. Runs daily via cron. Processes the renewal lifecycle: Upcoming → Due Soon (30 days out) → Invoice Sent → Paid → Completed ↓ New entry (next year) Lifecycle: 1. SCAN: Find entries where reminder_date <= today and status = "Upcoming" 2. NOTIFY: Change status to "Due Soon", send reminder email to client 3. INVOICE: For billable entries (amount_usd > 0 or amount_cad > 0), create a Sales Invoice in ERPNext grouped by customer 4. TRACK: Monitor invoice payment status via ERPNext 5. COMPLETE: When invoice is paid, mark entry as "Completed" 6. RE-CALENDAR: Create a new entry for the next recurrence period 7. OVERDUE: If due_date has passed and not paid, mark as "Overdue" Billable items: - Annual Maintenance Fee: $349 USD (our service fee) - Mailbox Renewal: ~C$165 (vendor pass-through) - BC Annual Report: C$42 (government fee pass-through) - Domain Renewal: ~C$15 (vendor pass-through) Non-billable items (covered by maintenance fee): - CRTC Compliance Check, CCTS Membership, all CRTC surveys Cron: 0 7 * * * (daily at 7am CT) """ import logging import os import sys from datetime import datetime, timedelta LOG = logging.getLogger("renewal_worker") def get_erpnext_client(): """Return an ERPNext REST client.""" from scripts.workers.erpnext_client import ERPNextClient return ERPNextClient() def get_smtp_config(): """Return SMTP config from environment.""" return { "host": os.environ.get("SMTP_HOST", "co.carrierone.com"), "port": int(os.environ.get("SMTP_PORT", "587")), "user": os.environ.get("SMTP_USER", "noreply@performancewest.net"), "password": os.environ.get("SMTP_PASS", ""), "from_addr": os.environ.get("SMTP_FROM", "noreply@performancewest.net"), } def send_email(to_email: str, subject: str, html_body: str): """Send an email via Carbonio SMTP.""" import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText smtp_cfg = get_smtp_config() msg = MIMEMultipart("alternative") msg["From"] = f"Performance West <{smtp_cfg['from_addr']}>" msg["To"] = to_email msg["Subject"] = subject msg.attach(MIMEText(html_body, "html")) with smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"]) as server: server.starttls() server.login(smtp_cfg["user"], smtp_cfg["password"]) server.sendmail(smtp_cfg["from_addr"], to_email, msg.as_string()) LOG.info("Sent renewal email to %s: %s", to_email, subject) # ────────────────────────────────────────────────────────────────────────── # # Step 1: Transition Upcoming → Due Soon # ────────────────────────────────────────────────────────────────────────── # def process_upcoming_to_due_soon(erp): """Find entries where reminder_date <= today and status is Upcoming.""" today = datetime.utcnow().strftime("%Y-%m-%d") entries = erp.get_list("Compliance Calendar", filters={ "status": "Upcoming", "reminder_date": ["<=", today], }, fields=["name", "title", "entity_name", "order_reference", "compliance_type", "due_date", "amount_usd", "amount_cad", "customer"], limit_page_length=100) LOG.info("Found %d entries transitioning Upcoming → Due Soon", len(entries)) for entry in entries: try: erp.update_resource("Compliance Calendar", entry["name"], { "status": "Due Soon", }) LOG.info(" %s: %s → Due Soon (due %s)", entry["name"], entry["title"], entry["due_date"]) except Exception as exc: LOG.error("Failed to update %s: %s", entry["name"], exc) return entries # ────────────────────────────────────────────────────────────────────────── # # Step 2: Generate invoices for billable Due Soon entries # ────────────────────────────────────────────────────────────────────────── # def generate_renewal_invoices(erp): """Create Sales Invoices for billable compliance entries in Due Soon status. Groups entries by customer into a single invoice per customer. Only creates an invoice if one doesn't already exist for the entry. """ entries = erp.get_list("Compliance Calendar", filters={ "status": "Due Soon", "invoice": ["is", "not set"], }, fields=["name", "title", "entity_name", "order_reference", "compliance_type", "due_date", "amount_usd", "amount_cad", "customer"], limit_page_length=100) # Filter to only billable entries billable = [e for e in entries if (e.get("amount_usd") or 0) > 0 or (e.get("amount_cad") or 0) > 0] if not billable: LOG.info("No billable entries need invoicing") return # Group by customer by_customer = {} for entry in billable: cust = entry.get("customer") if not cust: # Try to get customer from the Sales Order if entry.get("order_reference"): try: so = erp.get_resource("Sales Order", entry["order_reference"]) cust = so.get("customer") except Exception: pass if cust: by_customer.setdefault(cust, []).append(entry) else: LOG.warning("No customer found for %s — skipping invoice", entry["name"]) for customer, cust_entries in by_customer.items(): try: _create_renewal_invoice(erp, customer, cust_entries) except Exception as exc: LOG.error("Failed to create renewal invoice for %s: %s", customer, exc) def _create_renewal_invoice(erp, customer: str, entries: list): """Create a single Sales Invoice for a customer's renewal entries.""" # Build invoice items items = [] total_usd = 0 for entry in entries: amount_usd = entry.get("amount_usd") or 0 amount_cad = entry.get("amount_cad") or 0 # Convert CAD to USD for invoicing (approximate — use 0.74 rate) # In production, this should use Bank of Canada daily rate cad_to_usd = 0.74 entry_usd = amount_usd + (amount_cad * cad_to_usd) if entry_usd <= 0: continue items.append({ "item_code": _compliance_type_to_item(entry.get("compliance_type", "")), "description": entry.get("title", entry.get("compliance_type", "Renewal")), "qty": 1, "rate": round(entry_usd, 2), }) total_usd += entry_usd if not items: return # Create Sales Invoice entity_name = entries[0].get("entity_name", "") invoice_data = { "doctype": "Sales Invoice", "customer": customer, "due_date": entries[0].get("due_date"), "items": items, "custom_invoice_type": "Renewal", "remarks": f"Annual renewal for {entity_name}" if entity_name else "Annual renewal", } try: inv = erp.create_resource("Sales Invoice", invoice_data) inv_name = inv.get("name", "") LOG.info("Created renewal invoice %s for %s ($%.2f USD, %d items)", inv_name, customer, total_usd, len(items)) # Submit the invoice try: erp.submit_resource("Sales Invoice", inv_name) LOG.info("Submitted invoice %s", inv_name) except Exception as sub_exc: LOG.warning("Failed to submit invoice %s: %s", inv_name, sub_exc) # Link invoice to each compliance entry and update status for entry in entries: try: erp.update_resource("Compliance Calendar", entry["name"], { "invoice": inv_name, "status": "Invoice Sent", }) except Exception as link_exc: LOG.warning("Failed to link invoice to %s: %s", entry["name"], link_exc) # Send renewal invoice email to customer _send_renewal_email(erp, customer, entries, inv_name, total_usd) except Exception as exc: LOG.error("Failed to create invoice for %s: %s", customer, exc) raise def _compliance_type_to_item(compliance_type: str) -> str: """Map compliance type to ERPNext Item code for invoicing.""" mapping = { "Annual Maintenance Fee": "CRTC-MAINT-ANNUAL", "Mailbox Renewal": "MAILBOX-RENEWAL", "BC Annual Report": "BC-ANNUAL-REPORT", "Domain Renewal": "DOMAIN-RENEWAL-CA", } return mapping.get(compliance_type, "COMPLIANCE-OTHER") def _send_renewal_email(erp, customer: str, entries: list, invoice_name: str, total_usd: float): """Send renewal reminder email to the customer.""" # Get customer email try: cust = erp.get_resource("Customer", customer) email = cust.get("email_id", "") cust_name = cust.get("customer_name", customer) except Exception: LOG.warning("Could not get customer email for %s", customer) return if not email: LOG.warning("No email for customer %s — skipping renewal email", customer) return entity_name = entries[0].get("entity_name", cust_name) due_date = entries[0].get("due_date", "") items_html = "" for entry in entries: amt = entry.get("amount_usd") or 0 cad = entry.get("amount_cad") or 0 price = f"${amt:.2f} USD" if amt > 0 else f"C${cad:.2f}" if cad > 0 else "Included" items_html += f"{entry.get('title', '')}" items_html += f"{price}" html = f"""

Annual Renewal — {entity_name}

Your annual compliance renewals for {entity_name} are coming due on {due_date}.

{items_html}
Item Amount
Total ${total_usd:.2f} USD

Invoice {invoice_name} has been generated. You can view and pay your invoice at:

View Invoice & Pay

What's included in your annual maintenance:

If you have any questions about your renewal, reply to this email or contact us at support@performancewest.net.

Performance West Inc. — Canadian Telecom Carrier Registration Services

""" try: send_email(email, f"Annual Renewal Due — {entity_name}", html) except Exception as exc: LOG.warning("Failed to send renewal email to %s: %s", email, exc) # ────────────────────────────────────────────────────────────────────────── # # Step 3: Check payment status on Invoice Sent entries # ────────────────────────────────────────────────────────────────────────── # def check_invoice_payments(erp): """Check if invoices linked to 'Invoice Sent' entries have been paid.""" entries = erp.get_list("Compliance Calendar", filters={ "status": "Invoice Sent", "invoice": ["is", "set"], }, fields=["name", "title", "invoice", "entity_name", "compliance_type", "order_reference", "due_date", "recurring", "recurrence_period", "amount_usd", "amount_cad", "customer", "state_code", "category", "description"], limit_page_length=100) LOG.info("Checking payment status for %d invoiced entries", len(entries)) for entry in entries: inv_name = entry.get("invoice") if not inv_name: continue try: inv = erp.get_resource("Sales Invoice", inv_name) outstanding = inv.get("outstanding_amount", 1) status = inv.get("status", "") if outstanding <= 0 or status == "Paid": LOG.info(" %s: Invoice %s is PAID — completing entry", entry["name"], inv_name) _complete_and_recalendar(erp, entry) elif status in ("Overdue", "Unpaid"): # Check if past due date due = entry.get("due_date", "") if due and due < datetime.utcnow().strftime("%Y-%m-%d"): erp.update_resource("Compliance Calendar", entry["name"], { "status": "Overdue", }) LOG.info(" %s: Marked as Overdue (due %s)", entry["name"], due) except Exception as exc: LOG.warning("Failed to check invoice %s: %s", inv_name, exc) # ────────────────────────────────────────────────────────────────────────── # # Step 4: Mark overdue entries (no invoice, past due date) # ────────────────────────────────────────────────────────────────────────── # def mark_overdue(erp): """Mark entries past their due date as Overdue.""" today = datetime.utcnow().strftime("%Y-%m-%d") entries = erp.get_list("Compliance Calendar", filters={ "status": ["in", ["Upcoming", "Due Soon"]], "due_date": ["<", today], }, fields=["name", "title", "due_date"], limit_page_length=100) for entry in entries: try: erp.update_resource("Compliance Calendar", entry["name"], { "status": "Overdue", }) LOG.info(" %s: %s → Overdue (was due %s)", entry["name"], entry["title"], entry["due_date"]) except Exception as exc: LOG.warning("Failed to mark %s overdue: %s", entry["name"], exc) # ────────────────────────────────────────────────────────────────────────── # # Step 5: Complete paid entries and create next year's entries # ────────────────────────────────────────────────────────────────────────── # def _complete_and_recalendar(erp, entry: dict): """Mark an entry as Completed, then create the next recurrence. This is the core renewal logic: 1. Set status = "Paid" (payment received) 2. Perform the compliance action (or create admin ToDo) 3. Set status = "Completed" with completed_at timestamp 4. If recurring, create a new entry for the next period """ entry_name = entry["name"] now = datetime.utcnow() # Mark as Paid erp.update_resource("Compliance Calendar", entry_name, { "status": "Paid", }) # Create admin ToDo to perform the actual compliance action compliance_type = entry.get("compliance_type", "") entity_name = entry.get("entity_name", "") _create_action_todo(erp, entry) # Mark as Completed erp.update_resource("Compliance Calendar", entry_name, { "status": "Completed", "completed_at": now.isoformat(), }) LOG.info("Completed: %s (%s)", entry_name, entry.get("title", "")) # Re-calendar if recurring if entry.get("recurring"): _create_next_recurrence(erp, entry, entry_name) def _create_action_todo(erp, entry: dict): """Create an admin ToDo to perform the compliance action now that it's paid.""" compliance_type = entry.get("compliance_type", "") entity_name = entry.get("entity_name", "") order_ref = entry.get("order_reference", "") # Only create ToDos for actionable items actionable = { "Mailbox Renewal": f"Renew Anytime Mailbox for {entity_name}. Log into AMB and process renewal payment.", "BC Annual Report": f"File BC Annual Report for {entity_name} via Corporate Online.", "Domain Renewal": f"Verify .ca domain auto-renewal for {entity_name} on Porkbun.", "CRTC Compliance Check": f"Perform annual CRTC compliance review for {entity_name}.", "CCTS Membership": f"Verify CCTS membership is current for {entity_name}.", } action = actionable.get(compliance_type) if not action: return try: erp.create_resource("ToDo", { "doctype": "ToDo", "description": f"**Renewal Action — {compliance_type}**\n\n{action}", "reference_type": "Sales Order", "reference_name": order_ref, "priority": "Medium", "status": "Open", "allocated_to": "Administrator", }) LOG.info("Created action ToDo for %s: %s", entity_name, compliance_type) except Exception as exc: LOG.warning("Failed to create action ToDo: %s", exc) def _create_next_recurrence(erp, entry: dict, prev_entry_name: str): """Create a new compliance calendar entry for the next recurrence period.""" period = entry.get("recurrence_period", "Yearly") due_date_str = entry.get("due_date", "") if not due_date_str: LOG.warning("No due_date on %s — cannot re-calendar", prev_entry_name) return # Parse current due date try: due_date = datetime.strptime(due_date_str, "%Y-%m-%d") except ValueError: LOG.warning("Invalid due_date format: %s", due_date_str) return # Calculate next due date if period == "Yearly": next_due = due_date.replace(year=due_date.year + 1) next_remind = next_due - timedelta(days=30) elif period == "Quarterly": next_due = due_date + timedelta(days=90) next_remind = next_due - timedelta(days=14) elif period == "Monthly": month = due_date.month + 1 year = due_date.year if month > 12: month = 1 year += 1 next_due = due_date.replace(year=year, month=month) next_remind = next_due - timedelta(days=7) else: LOG.warning("Unknown recurrence period: %s", period) return # Create new entry new_entry = { "doctype": "Compliance Calendar", "customer": entry.get("customer", ""), "entity_name": entry.get("entity_name", ""), "order_reference": entry.get("order_reference", ""), "title": entry.get("title", ""), "compliance_type": entry.get("compliance_type", ""), "category": entry.get("category", "Other"), "description": entry.get("description", ""), "due_date": next_due.strftime("%Y-%m-%d"), "reminder_date": next_remind.strftime("%Y-%m-%d"), "state_code": entry.get("state_code", ""), "amount_usd": entry.get("amount_usd", 0), "amount_cad": entry.get("amount_cad", 0), "recurring": 1, "recurrence_period": period, "renewal_of": prev_entry_name, "status": "Upcoming", } try: created = erp.create_resource("Compliance Calendar", new_entry) LOG.info("Re-calendared: %s → %s (due %s)", prev_entry_name, created.get("name", "?"), next_due.strftime("%Y-%m-%d")) except Exception as exc: LOG.error("Failed to re-calendar %s: %s", prev_entry_name, exc) # ────────────────────────────────────────────────────────────────────────── # # Also handle direct payment events (webhook-triggered) # ────────────────────────────────────────────────────────────────────────── # def handle_renewal_payment(invoice_name: str): """Called by webhook when a renewal invoice is paid. This provides immediate completion instead of waiting for the daily cron. """ erp = get_erpnext_client() # Find all compliance entries linked to this invoice entries = erp.get_list("Compliance Calendar", filters={ "invoice": invoice_name, "status": ["in", ["Invoice Sent", "Overdue"]], }, fields=["name", "title", "invoice", "entity_name", "compliance_type", "order_reference", "due_date", "recurring", "recurrence_period", "amount_usd", "amount_cad", "customer", "state_code", "category", "description"], limit_page_length=50) LOG.info("Processing payment for invoice %s — %d entries", invoice_name, len(entries)) for entry in entries: try: _complete_and_recalendar(erp, entry) except Exception as exc: LOG.error("Failed to complete %s on payment: %s", entry["name"], exc) # ────────────────────────────────────────────────────────────────────────── # # FCC 499-A supporting reminders — reseller certs, CALEA review, traffic study # ────────────────────────────────────────────────────────────────────────── # def _db_connect(): """Return a psycopg2 connection to the main DB, or None on failure.""" try: import psycopg2 return psycopg2.connect(os.environ.get("DATABASE_URL", "")) except Exception as exc: LOG.warning("No DB connection for FCC reminders: %s", exc) return None def process_reseller_cert_reminders(): """Send T-30 / T-14 / T-7 / T-1 reminders for reseller certifications. Per 2026 Form 499-A Section IV.C.4, certifications must be renewed annually. A lapsed cert invalidates Line 303 revenue claims. """ conn = _db_connect() if not conn: return try: import psycopg2.extras with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: # Find active certs whose renewal_due matches one of our reminder # offsets from today. The T-N SQL below: renewal_due - N = today. cur.execute( """ SELECT rc.id, rc.reseller_legal_name, rc.reseller_filer_id_499, rc.reseller_contact_email, rc.renewal_due, (rc.renewal_due - CURRENT_DATE)::int AS days_left, te.legal_name AS filer_legal_name, te.customer_id FROM reseller_certifications rc JOIN telecom_entities te ON te.id = rc.filer_telecom_entity_id WHERE rc.status = 'active' AND (rc.renewal_due - CURRENT_DATE) IN (30, 14, 7, 1) """ ) rows = cur.fetchall() or [] for r in rows: days = r["days_left"] # Look up the customer email from the customers table with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( "SELECT email, name FROM customers WHERE id = %s", (r["customer_id"],), ) cust = cur.fetchone() or {} if not cust.get("email"): LOG.info( "Reseller cert reminder: no customer email for filer %s " "(cert %s)", r["filer_legal_name"], r["id"], ) continue subject = ( f"Action required: Reseller certification for " f"{r['reseller_legal_name']} expires in {days} day" f"{'s' if days != 1 else ''}" ) body = f"""\

Hi {cust.get('name', '')},

Your Form 499-A reseller certification for {r['reseller_legal_name']} (Filer ID {r['reseller_filer_id_499']}) is due for renewal on {r['renewal_due']} — {days} day{'s' if days != 1 else ''} from today.

Without a current signed certification from this reseller, you cannot claim their revenue on Form 499-A Line 303. Under FCC Section IV.C.4, reseller certifications must be renewed annually.

Log in to your Performance West portal to request an updated signed certification, or contact your reseller directly.

— Performance West

""" try: send_email(cust["email"], subject, body) LOG.info("Sent T-%d reseller cert reminder for %s", days, r["id"]) except Exception as exc: LOG.warning("Reseller reminder email failed for %s: %s", r["id"], exc) except Exception as exc: LOG.exception("process_reseller_cert_reminders: %s", exc) finally: try: conn.close() except Exception: pass def process_calea_review_reminders(): """Annual CALEA SSI review reminder — 1 year after calea_ssi_generated_at.""" conn = _db_connect() if not conn: return try: import psycopg2.extras with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( """ SELECT te.id, te.legal_name, te.customer_id, te.calea_ssi_generated_at, (CURRENT_DATE - te.calea_ssi_generated_at::date)::int AS days_since FROM telecom_entities te WHERE te.calea_ssi_generated_at IS NOT NULL AND te.calea_ssi_generated_at::date <= CURRENT_DATE - INTERVAL '11 months' AND (te.calea_ssi_next_review_date IS NULL OR te.calea_ssi_next_review_date <= CURRENT_DATE + INTERVAL '30 days') """ ) rows = cur.fetchall() or [] for r in rows: LOG.info( "CALEA review due for %s (%d days since last plan)", r["legal_name"], r["days_since"], ) # Pull customer email similarly to above — simplified, just log with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( "SELECT email, name FROM customers WHERE id = %s", (r["customer_id"],), ) cust = cur.fetchone() or {} if not cust.get("email"): continue try: send_email( cust["email"], f"CALEA SSI annual review due for {r['legal_name']}", f"""

Hi {cust.get('name', '')},

47 USC 229 + 47 CFR 1.20003 require your CALEA System Security & Integrity plan to be reviewed annually. Your current plan was generated {r['days_since']} days ago.

Log into your Performance West portal to schedule the annual review (we'll regenerate the plan with any current-year updates).

— Performance West

""", ) except Exception as exc: LOG.warning("CALEA reminder email failed for %s: %s", r["id"], exc) except Exception as exc: LOG.exception("process_calea_review_reminders: %s", exc) finally: try: conn.close() except Exception: pass def process_traffic_study_resubmissions(): """If a traffic-study PDF was updated after USAC submission, re-stamp + re-submit. Placeholder: real implementation would compare pdf_minio_path mtime against usac_submitted_at and trigger the stamper + USAC upload. For now, just log candidates for admin awareness. """ conn = _db_connect() if not conn: return try: import psycopg2.extras with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( """ SELECT id, profile_id, reporting_year, usac_submitted_at, fcc_compliance_ok FROM cdr_traffic_studies WHERE fcc_compliance_ok = TRUE AND usac_submitted_at IS NOT NULL AND generated_at > usac_submitted_at LIMIT 20 """ ) rows = cur.fetchall() or [] for r in rows: LOG.info( "Traffic study %s (profile %s, year %s) regenerated after USAC submission — admin re-submit advised", r["id"], r["profile_id"], r["reporting_year"], ) except Exception as exc: LOG.exception("process_traffic_study_resubmissions: %s", exc) finally: try: conn.close() except Exception: pass # ────────────────────────────────────────────────────────────────────────── # # Main — daily cron entry point # ────────────────────────────────────────────────────────────────────────── # def main(): """Run all renewal processing steps.""" logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) LOG.info("=== Renewal Worker starting ===") try: erp = get_erpnext_client() # Step 1: Upcoming → Due Soon (triggers 30 days before due date) process_upcoming_to_due_soon(erp) # Step 2: Generate invoices for billable Due Soon entries generate_renewal_invoices(erp) # Step 3: Check invoice payments → Complete + re-calendar check_invoice_payments(erp) # Step 4: Mark unpaid past-due entries as Overdue mark_overdue(erp) # Step 5: FCC reseller certification renewal reminders (T-30/14/7/1) process_reseller_cert_reminders() # Step 6: CALEA SSI annual review reminders process_calea_review_reminders() # Step 7: Traffic study FCC re-submission check process_traffic_study_resubmissions() LOG.info("=== Renewal Worker complete ===") except Exception as exc: LOG.exception("Renewal Worker failed: %s", exc) sys.exit(1) if __name__ == "__main__": main()