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>
790 lines
32 KiB
Python
790 lines
32 KiB
Python
#!/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"<tr><td style='padding:8px;border-bottom:1px solid #eee;'>{entry.get('title', '')}</td>"
|
|
items_html += f"<td style='padding:8px;border-bottom:1px solid #eee;text-align:right;'>{price}</td></tr>"
|
|
|
|
html = f"""
|
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
<h2 style="color: #1e3a5f;">Annual Renewal — {entity_name}</h2>
|
|
|
|
<p>Your annual compliance renewals for <strong>{entity_name}</strong> are
|
|
coming due on <strong>{due_date}</strong>.</p>
|
|
|
|
<table style="width:100%; border-collapse:collapse; margin:20px 0;">
|
|
<thead>
|
|
<tr style="background:#1e3a5f; color:#fff;">
|
|
<th style="padding:10px; text-align:left;">Item</th>
|
|
<th style="padding:10px; text-align:right;">Amount</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items_html}
|
|
<tr style="font-weight:bold; background:#f5f5f5;">
|
|
<td style="padding:10px;">Total</td>
|
|
<td style="padding:10px; text-align:right;">${total_usd:.2f} USD</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p>Invoice <strong>{invoice_name}</strong> has been generated. You can view
|
|
and pay your invoice at:</p>
|
|
|
|
<p style="text-align:center; margin:20px 0;">
|
|
<a href="https://portal.performancewest.net/sales-invoices/{invoice_name}"
|
|
style="background:#1e3a5f; color:#fff; padding:12px 30px; text-decoration:none;
|
|
border-radius:4px; display:inline-block;">
|
|
View Invoice & Pay
|
|
</a>
|
|
</p>
|
|
|
|
<p><strong>What's included in your annual maintenance:</strong></p>
|
|
<ul>
|
|
<li>Registered agent service (BC)</li>
|
|
<li>CRTC compliance monitoring and annual review</li>
|
|
<li>BC Annual Report filing</li>
|
|
<li>REP-T/T1 survey preparation and filing assistance</li>
|
|
<li>CCTS membership maintenance</li>
|
|
<li>General compliance support</li>
|
|
</ul>
|
|
|
|
<p>If you have any questions about your renewal, reply to this email or
|
|
contact us at <a href="mailto:support@performancewest.net">support@performancewest.net</a>.</p>
|
|
|
|
<p style="color: #666; font-size: 0.9em; margin-top: 30px;">
|
|
Performance West Inc. — Canadian Telecom Carrier Registration Services
|
|
</p>
|
|
</div>
|
|
"""
|
|
|
|
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"""\
|
|
<p>Hi {cust.get('name', '')},</p>
|
|
|
|
<p>Your Form 499-A reseller certification for
|
|
<strong>{r['reseller_legal_name']}</strong>
|
|
(Filer ID {r['reseller_filer_id_499']}) is due for renewal on
|
|
<strong>{r['renewal_due']}</strong> — {days} day{'s' if days != 1 else ''} from today.</p>
|
|
|
|
<p>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.</p>
|
|
|
|
<p>Log in to your Performance West portal to request an updated signed
|
|
certification, or contact your reseller directly.</p>
|
|
|
|
<p>— Performance West</p>
|
|
"""
|
|
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"""<p>Hi {cust.get('name', '')},</p>
|
|
<p>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.</p>
|
|
<p>Log into your Performance West portal to schedule the annual review
|
|
(we'll regenerate the plan with any current-year updates).</p>
|
|
<p>— Performance West</p>""",
|
|
)
|
|
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()
|