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>
This commit is contained in:
justin 2026-05-03 02:28:04 -05:00
parent 3e04a8fc16
commit 572f0cbf93
5 changed files with 305 additions and 26 deletions

View file

@ -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,

View file

@ -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

View file

@ -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 <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()

View file

@ -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",

View file

@ -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)