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:
parent
3e04a8fc16
commit
572f0cbf93
5 changed files with 305 additions and 26 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
207
scripts/workers/quarterly_499q_notify.py
Normal file
207
scripts/workers/quarterly_499q_notify.py
Normal 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 →
|
||||
</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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue