new-site/scripts/workers/quarterly_499q_notify.py
justin 572f0cbf93 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) <noreply@anthropic.com>
2026-05-03 02:28:04 -05:00

207 lines
7.7 KiB
Python

#!/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 <noreply@performancewest.net>")
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"""
<div style="font-family:Inter,system-ui,sans-serif;max-width:600px;margin:0 auto;color:#1f2937">
<div style="background:#1e3a5f;padding:20px 24px;border-radius:8px 8px 0 0">
<h2 style="color:#fff;margin:0;font-size:18px">FCC Form 499-Q — {quarter} Filing</h2>
</div>
<div style="padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px">
<p>Hi,</p>
<p>This is a reminder that the <strong>FCC Form 499-Q ({quarter})</strong> quarterly
filing for <strong>{entity_name}</strong> (Filer ID: {filer_id}) is due
<strong>{due_date}</strong>{f' — in {days_until} days' if days_until > 0 else ' — today'}.</p>
<p>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.</p>
<div style="text-align:center;margin:24px 0">
<a href="{intake_url}"
style="display:inline-block;padding:12px 32px;background:#1e3a5f;color:#fff;
font-weight:600;border-radius:8px;text-decoration:none;font-size:14px">
Complete 499-Q Filing &rarr;
</a>
</div>
<p style="font-size:13px;color:#6b7280">
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
<a href="mailto:ops@performancewest.net">ops@performancewest.net</a>.
</p>
{'<p style="font-size:13px;color:#dc2626;font-weight:600">⚠️ Late 499-Q filings may result in estimated USF contributions calculated by USAC, which are typically higher than actual amounts.</p>' if days_until <= 7 else ''}
</div>
</div>
"""
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()