#!/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"
Your annual compliance renewals for {entity_name} are coming due on {due_date}.
| Item | Amount |
|---|---|
| Total | ${total_usd:.2f} USD |
Invoice {invoice_name} has been generated. You can view and pay your invoice at:
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
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()