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>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

View file

@ -0,0 +1,741 @@
"""
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()