From 572f0cbf93be6777a54f0fdb47b24d40a6706c1b Mon Sep 17 00:00:00 2001 From: justin Date: Sun, 3 May 2026 02:28:04 -0500 Subject: [PATCH] Implement 499-Q quarterly filing lifecycle After 499-A+Q bundle is filed, the handler now creates actual compliance_orders for each remaining quarterly 499-Q filing: Schedule: Q1 due Feb 1, Q2 due May 1, Q3 due Aug 1, Q4 due Nov 1 Each quarterly order: - Created as paid (covered by bundle price) - Has due_date, quarter, period_end_date in intake_data - Links to parent 499-A order - Tracks reminder status (30d/14d/7d sent flags) Notification worker (quarterly_499q_notify.py): - Runs daily at 8am CT via systemd timer - Sends HTML reminder emails at 30, 14, 7 days before due - Email includes intake link for client to submit quarterly data - Late warning at 7 days: "USAC may estimate higher contributions" - Idempotent: won't re-send same reminder level Added fcc-499q service slug ($0, not sold standalone). Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/routes/compliance-orders.ts | 6 + .../roles/worker-crons/defaults/main.yml | 9 + scripts/workers/quarterly_499q_notify.py | 207 ++++++++++++++++++ scripts/workers/services/__init__.py | 1 + scripts/workers/services/form_499a.py | 108 ++++++--- 5 files changed, 305 insertions(+), 26 deletions(-) create mode 100644 scripts/workers/quarterly_499q_notify.py diff --git a/api/src/routes/compliance-orders.ts b/api/src/routes/compliance-orders.ts index dc7a086..fde6551 100644 --- a/api/src/routes/compliance-orders.ts +++ b/api/src/routes/compliance-orders.ts @@ -142,6 +142,12 @@ const COMPLIANCE_SERVICES: Record< erpnext_item: "OCN-REGISTRATION", discountable: false, }, + "fcc-499q": { + name: "FCC Form 499-Q Quarterly Filing", + price_cents: 0, // included in 499-A+Q bundle — not sold standalone + erpnext_item: "FCC-499Q", + discountable: false, + }, "fcc-499a-discontinuance": { name: "Form 499-A Discontinuance Filing", price_cents: 29900, diff --git a/infra/ansible/roles/worker-crons/defaults/main.yml b/infra/ansible/roles/worker-crons/defaults/main.yml index 57bc447..11c3997 100644 --- a/infra/ansible/roles/worker-crons/defaults/main.yml +++ b/infra/ansible/roles/worker-crons/defaults/main.yml @@ -157,3 +157,12 @@ worker_crons: module: scripts.workers.fcc_rmd_auditor --batch --year 2026 --no-ollama on_calendar: "Sat *-*-* 10:00:00 UTC" persistent: true + + # 499-Q quarterly filing reminders — daily 13:00 UTC (8am CT). + # Sends reminder emails at 30/14/7 days before each quarterly due date. + # Creates compliance_orders for each quarter when the 499-A+Q bundle is filed. + - name: pw-499q-notify + description: Send 499-Q quarterly filing reminders and intake links + module: scripts.workers.quarterly_499q_notify + on_calendar: "*-*-* 13:00:00 UTC" + persistent: true diff --git a/scripts/workers/quarterly_499q_notify.py b/scripts/workers/quarterly_499q_notify.py new file mode 100644 index 0000000..e5709ad --- /dev/null +++ b/scripts/workers/quarterly_499q_notify.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""FCC Form 499-Q Quarterly Filing Notification Worker. + +Runs daily via cron. Finds upcoming 499-Q compliance_orders and sends +reminder emails with intake links at 30, 14, and 7 days before due date. + +After the client completes intake, the 499-Q handler files at USAC. + +Lifecycle: + 499-A filed → 499-Q orders created (by Form499ABundleHandler) + ↓ + 30 days before due → first reminder email + 14 days before due → second reminder + 7 days before due → urgent reminder + ↓ + Client fills intake → worker files 499-Q + ↓ + Filed → confirmation email + +Cron: 0 8 * * * (daily at 8am CT) +""" +from __future__ import annotations + +import json +import logging +import os +import smtplib +from datetime import date, timedelta +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import psycopg2 +import psycopg2.extras + +LOG = logging.getLogger("workers.quarterly_499q_notify") + +SMTP_HOST = os.environ.get("SMTP_HOST", "co.carrierone.com") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USER = os.environ.get("SMTP_USER", "noreply@performancewest.net") +SMTP_PASS = os.environ.get("SMTP_PASS", "") +SMTP_FROM = os.environ.get("SMTP_FROM", "Performance West ") +DOMAIN = os.environ.get("DOMAIN", "performancewest.net") + + +def _send_email(to: str, subject: str, html: str) -> bool: + try: + msg = MIMEMultipart("alternative") + msg["From"] = SMTP_FROM + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(html, "html")) + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s: + s.starttls() + s.login(SMTP_USER, SMTP_PASS) + s.send_message(msg) + return True + except Exception as exc: + LOG.warning("Email send failed to %s: %s", to, exc) + return False + + +def _build_reminder_email( + entity_name: str, + quarter: str, + due_date: str, + days_until: int, + order_number: str, + filer_id: str, +) -> tuple[str, str]: + """Return (subject, html_body) for a 499-Q reminder.""" + + urgency = "Reminder" if days_until > 14 else "Upcoming" if days_until > 7 else "Urgent" + subject = f"{urgency}: FCC Form 499-Q ({quarter}) due {due_date} — {entity_name}" + + intake_url = f"https://{DOMAIN}/order/fcc-499q?order={order_number}" + + html = f""" +
+
+

FCC Form 499-Q — {quarter} Filing

+
+
+

Hi,

+

This is a reminder that the FCC Form 499-Q ({quarter}) quarterly + filing for {entity_name} (Filer ID: {filer_id}) is due + {due_date}{f' — in {days_until} days' if days_until > 0 else ' — today'}.

+ +

The 499-Q reports your projected quarterly USF contributions based on actual + telecom revenue for the prior quarter. USAC uses this to calculate your quarterly + contribution payment.

+ + + +

+ This filing is included in your 499-A + 499-Q bundle — no additional charge. + If you need assistance, reply to this email or contact us at + ops@performancewest.net. +

+ + {'

⚠️ Late 499-Q filings may result in estimated USF contributions calculated by USAC, which are typically higher than actual amounts.

' if days_until <= 7 else ''} +
+
+ """ + return subject, html + + +def run(dry_run: bool = False) -> dict: + """Check for upcoming 499-Q filings and send reminders.""" + conn = psycopg2.connect(os.environ["DATABASE_URL"]) + today = date.today() + stats = {"checked": 0, "reminded_30d": 0, "reminded_14d": 0, "reminded_7d": 0, "skipped": 0} + + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(""" + SELECT co.order_number, co.customer_email, co.customer_name, + co.intake_data, co.telecom_entity_id + FROM compliance_orders co + WHERE co.service_slug = 'fcc-499q' + AND co.payment_status = 'paid' + AND co.intake_data IS NOT NULL + AND (co.intake_data->>'intake_completed')::boolean IS NOT TRUE + """) + rows = cur.fetchall() + stats["checked"] = len(rows) + + for row in rows: + intake = row["intake_data"] or {} + due_str = intake.get("due_date") + if not due_str: + continue + + due = date.fromisoformat(due_str) + days_until = (due - today).days + quarter = intake.get("quarter", "?") + entity_name = intake.get("entity_name", row["customer_name"]) + filer_id = intake.get("filer_id_499", "") + email = row["customer_email"] + order_number = row["order_number"] + + # Already past due — skip (handled by overdue process) + if days_until < -7: + stats["skipped"] += 1 + continue + + # Determine which reminder to send + reminder_key = None + if days_until <= 7 and not intake.get("reminder_sent_7d"): + reminder_key = "reminder_sent_7d" + stats["reminded_7d"] += 1 + elif days_until <= 14 and not intake.get("reminder_sent_14d"): + reminder_key = "reminder_sent_14d" + stats["reminded_14d"] += 1 + elif days_until <= 30 and not intake.get("reminder_sent_30d"): + reminder_key = "reminder_sent_30d" + stats["reminded_30d"] += 1 + + if not reminder_key: + continue + + LOG.info( + "499-Q %s %s: %s due %s (%d days) — sending %s to %s", + order_number, quarter, entity_name, due_str, days_until, reminder_key, email, + ) + + if not dry_run: + subject, html = _build_reminder_email( + entity_name=entity_name, + quarter=quarter, + due_date=due_str, + days_until=max(days_until, 0), + order_number=order_number, + filer_id=filer_id, + ) + sent = _send_email(email, subject, html) + if sent: + # Mark reminder as sent + intake[reminder_key] = True + cur.execute( + "UPDATE compliance_orders SET intake_data = %s, updated_at = now() WHERE order_number = %s", + (json.dumps(intake), order_number), + ) + conn.commit() + else: + LOG.info("DRY RUN: would send %s to %s for %s", reminder_key, email, order_number) + + conn.close() + LOG.info("499-Q notify: %s", stats) + return stats + + +def main(): + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + run(dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/scripts/workers/services/__init__.py b/scripts/workers/services/__init__.py index 6ed94cc..26445f1 100644 --- a/scripts/workers/services/__init__.py +++ b/scripts/workers/services/__init__.py @@ -99,6 +99,7 @@ FCC_SERVICE_SLUGS: frozenset[str] = frozenset({ "fcc-499a", "fcc-499a-zero", "fcc-499a-499q", + "fcc-499q", "stir-shaken", "bdc-filing", "fcc-full-compliance", diff --git a/scripts/workers/services/form_499a.py b/scripts/workers/services/form_499a.py index 8bb9b97..09203b1 100644 --- a/scripts/workers/services/form_499a.py +++ b/scripts/workers/services/form_499a.py @@ -1395,39 +1395,95 @@ class Form499AHandler(BaseServiceHandler): # ------------------------------------------------------------------ # def _schedule_499q_calendar(self, order_number: str, entity: dict) -> None: + """Create compliance_orders for each remaining 499-Q quarter. + + Each quarterly order: + - Has a due_date so the 499-Q notification cron can send reminders + - Links to the parent 499-A order via intake_data.parent_499a_order + - Starts in 'awaiting_intake' so the client receives a form link + - Is $0 (covered by the bundle price) + + The renewal_worker / 499q_notify cron handles: + 30 days before → first reminder email with intake link + 14 days before → second reminder + 7 days before → urgent reminder + Due date → final warning + """ if entity.get("is_deminimis"): logger.info( "Form499AHandler: de minimis carrier — skipping 499-Q calendar", ) return + + year = datetime.utcnow().year + # 499-Q quarters: Q1 due Feb 1, Q2 due May 1, Q3 due Aug 1, Q4 due Nov 1 + quarters = [ + {"quarter": "Q1", "due": date(year, 2, 1), "period_end": date(year - 1, 12, 31)}, + {"quarter": "Q2", "due": date(year, 5, 1), "period_end": date(year, 3, 31)}, + {"quarter": "Q3", "due": date(year, 8, 1), "period_end": date(year, 6, 30)}, + {"quarter": "Q4", "due": date(year, 11, 1), "period_end": date(year, 9, 30)}, + ] + today = date.today() + upcoming = [q for q in quarters if q["due"] >= today] + if not upcoming: + # All quarters passed this year — schedule Q1 of next year + upcoming = [{"quarter": "Q1", "due": date(year + 1, 2, 1), + "period_end": date(year, 12, 31)}] + try: - from scripts.workers.erpnext_client import ERPNextClient - erp = ERPNextClient() - year = datetime.utcnow().year - due_dates = [date(year, 2, 1), date(year, 5, 1), - date(year, 8, 1), date(year, 11, 1)] - today = date.today() - due_dates = [d for d in due_dates if d >= today] - if not due_dates: - due_dates = [date(year + 1, 2, 1)] - for d in due_dates: - erp.create_resource( - "Compliance Calendar", - { - "entity_name": entity.get("legal_name", ""), - "order_reference": order_number, - "compliance_type": "FCC Form 499-Q", - "description": ( - f"Quarterly FCC Form 499-Q filing for " - f"{entity.get('legal_name', '')} " - f"(FRN {entity.get('frn', '')})" + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + with conn.cursor() as cur: + for q in upcoming: + q_order = f"{order_number}-{q['quarter']}" + # Check if already created (idempotency) + cur.execute( + "SELECT 1 FROM compliance_orders WHERE order_number = %s", + (q_order,), + ) + if cur.fetchone(): + logger.info("499-Q %s already scheduled", q_order) + continue + + cur.execute( + """ + INSERT INTO compliance_orders ( + order_number, service_slug, customer_name, + customer_email, customer_phone, + telecom_entity_id, service_fee_cents, + payment_status, intake_data, created_at, updated_at + ) VALUES ( + %s, 'fcc-499q', %s, %s, %s, %s, 0, + 'paid', %s, now(), now() + ) + """, + ( + q_order, + entity.get("legal_name", ""), + entity.get("contact_email", ""), + entity.get("contact_phone", ""), + entity.get("id"), + json.dumps({ + "parent_499a_order": order_number, + "quarter": q["quarter"], + "due_date": q["due"].isoformat(), + "period_end_date": q["period_end"].isoformat(), + "filing_year": year, + "filer_id_499": entity.get("filer_id_499", ""), + "frn": entity.get("frn", ""), + "entity_name": entity.get("legal_name", ""), + "reminder_sent_30d": False, + "reminder_sent_14d": False, + "reminder_sent_7d": False, + "intake_completed": False, + }), ), - "due_date": d.strftime("%Y-%m-%d"), - "recurring": 1, - "recurrence_period": "Quarterly", - "status": "Upcoming", - }, - ) + ) + logger.info( + "Form499AHandler: scheduled 499-Q %s due %s for %s", + q["quarter"], q["due"], entity.get("legal_name", ""), + ) + conn.commit() + conn.close() except Exception as exc: logger.warning("Form499AHandler: could not schedule 499-Q: %s", exc)