Initial commit — Performance West telecom compliance platform

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>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

View file

@ -0,0 +1,124 @@
"""Service handler registry.
Maps service slugs (matching ERPNext item codes) to their handler classes.
"""
from .flsa_audit import FLSAAuditHandler
from .contractor_review import ContractorReviewHandler
from .handbook_review import HandbookReviewHandler
from .ccpa_audit import CCPAAuditHandler
from .privacy_policy import PrivacyPolicyHandler
from .breach_response import BreachResponseHandler
from .consent_audit import ConsentAuditHandler
from .dnc_review import DNCReviewHandler
from .campaign_review import CampaignReviewHandler
from .fcc_compliance_checkup import FCCComplianceCheckupHandler
# FCC remediation filings (automated Playwright submission to FCC / USAC / BDC)
from .rmd_filing import RMDFilingHandler
from .cpni_certification import CPNIFilingHandler
from .form_499a import Form499AHandler, Form499ABundleHandler
from .stir_shaken import StirShakenHandler
from .bdc_filing import BDCFilingHandler
from .fcc_full_compliance import FullComplianceHandler
from .dc_agent import DCAgentHandler
# NECA OCN / Company Code registration (admin-driven, no Playwright flow)
from .ocn_registration import OCNRegistrationHandler
# CDR Traffic Study analysis (rolls ingested CDRs into a 499-A study)
from .cdr_analysis import CDRAnalysisHandler
# New FCC onboarding + compliance filings
from .cores_frn_registration import CORESFRNRegistrationHandler
from .form_499_initial import Form499InitialHandler
from .calea_ssi import CALEASSIHandler
from .foreign_carrier_affiliation import ForeignCarrierAffiliationHandler
from .cdr_storage_tier import (
CDRStorageTier1Handler,
CDRStorageTier2Handler,
CDRStorageTier3Handler,
)
from .new_carrier_bundle import NewCarrierBundleHandler
# Foreign qualification (Certificate of Authority) across US states
from .foreign_qualification import ForeignQualificationHandler
# State PUC/PSC registration across US states
from .state_puc_filing import StatePucFilingHandler
SERVICE_HANDLERS: dict[str, type] = {
"flsa-audit": FLSAAuditHandler,
"contractor-classification": ContractorReviewHandler,
"handbook-review": HandbookReviewHandler,
"policy-development": HandbookReviewHandler, # same handler, different template
"ccpa-audit": CCPAAuditHandler,
"privacy-policy": PrivacyPolicyHandler,
"data-mapping": CCPAAuditHandler, # similar handler
"breach-response": BreachResponseHandler,
"consent-audit": ConsentAuditHandler,
"dnc-compliance": DNCReviewHandler,
"campaign-review": CampaignReviewHandler,
# ── FCC Compliance (diagnostic + remediation) ──────────────────────
"fcc-compliance-checkup": FCCComplianceCheckupHandler,
"rmd-filing": RMDFilingHandler,
"cpni-certification": CPNIFilingHandler,
"fcc-499a": Form499AHandler,
"fcc-499a-499q": Form499ABundleHandler,
"stir-shaken": StirShakenHandler,
# BDC triple — same handler, mode auto-resolved from slug
"bdc-filing": BDCFilingHandler, # legacy alias (both)
"bdc-broadband": BDCFilingHandler,
"bdc-voice": BDCFilingHandler,
"fcc-full-compliance": FullComplianceHandler,
"dc-agent": DCAgentHandler,
"ocn-registration": OCNRegistrationHandler,
"cdr-analysis": CDRAnalysisHandler,
# ── New FCC onboarding + compliance filings ────────────────────────
"cores-frn-registration": CORESFRNRegistrationHandler,
"fcc-499-initial": Form499InitialHandler,
"calea-ssi": CALEASSIHandler,
"fcc-63-11-notification": ForeignCarrierAffiliationHandler,
"new-carrier-bundle": NewCarrierBundleHandler,
# ── Foreign qualification (Certificate of Authority) ─────────────────
"foreign-qualification-single": ForeignQualificationHandler,
"foreign-qualification-multi": ForeignQualificationHandler, # same handler, fans out per-state
# ── State PUC/PSC registration ────────────────────────────────────
"state-puc": StatePucFilingHandler,
# ── CDR storage tier add-ons (quota bumps, not filings) ────────────
"cdr-storage-tier1": CDRStorageTier1Handler,
"cdr-storage-tier2": CDRStorageTier2Handler,
"cdr-storage-tier3": CDRStorageTier3Handler,
}
# Service slugs that operate on a telecom entity — used by job_server.py
# to decide whether to hydrate the entity row from PostgreSQL before
# dispatching to the handler.
FCC_SERVICE_SLUGS: frozenset[str] = frozenset({
"fcc-compliance-checkup",
"rmd-filing",
"cpni-certification",
"fcc-499a",
"fcc-499a-499q",
"stir-shaken",
"bdc-filing",
"fcc-full-compliance",
"dc-agent",
"ocn-registration",
"cdr-analysis",
# BDC triple (already covered by bdc-filing above; add explicit aliases)
"bdc-broadband",
"bdc-voice",
# New FCC filings
"cores-frn-registration",
"fcc-499-initial",
"calea-ssi",
"fcc-63-11-notification",
"new-carrier-bundle",
# CDR storage tiers — operate on a telecom entity's cdr_ingestion_profiles
"cdr-storage-tier1",
"cdr-storage-tier2",
"cdr-storage-tier3",
# Foreign qualification — may reference a telecom entity
"foreign-qualification-single",
"foreign-qualification-multi",
# State PUC/PSC — may reference a telecom entity
"state-puc",
})
__all__ = ["SERVICE_HANDLERS", "FCC_SERVICE_SLUGS"]

View file

@ -0,0 +1,443 @@
"""Abstract base class for all service handlers.
Every compliance service (FLSA audit, handbook review, etc.) inherits from
this class and implements the ``process()`` method.
"""
from __future__ import annotations
import logging
import os
import subprocess
import tempfile
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Directory containing DOCX templates
TEMPLATES_DIR = os.getenv(
"TEMPLATES_DIR",
str(Path(__file__).resolve().parent.parent.parent / "templates"),
)
# LLM configuration
LLM_API_URL = os.getenv("LLM_API_URL", "https://api.openai.com/v1/chat/completions")
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o")
LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.3"))
LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "4096"))
class BaseServiceHandler(ABC):
"""Base class for compliance service handlers."""
SERVICE_SLUG: str = ""
SERVICE_NAME: str = ""
TEMPLATE_NAME: str = "" # DOCX template filename in TEMPLATES_DIR
REQUIRES_LLM: bool = False
def __init__(self) -> None:
self.templates_dir = Path(TEMPLATES_DIR)
self._work_dir: str | None = None
# ------------------------------------------------------------------ #
# Abstract interface
# ------------------------------------------------------------------ #
@abstractmethod
async def process(self, order_data: dict) -> list[str]:
"""Process an order and return list of generated file paths.
Implementations should:
1. Load the DOCX template
2. (Optionally) call the LLM to generate section content
3. Fill the template with variables/content
4. Save as DOCX and convert to PDF
5. Return list of file paths
"""
raise NotImplementedError
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _build_output_path(self, order_number: str, filename: str) -> str:
"""Build the MinIO object path for output files."""
return f"compliance/{order_number}/{filename}"
def _get_template_path(self, template_name: str | None = None) -> Path:
"""Resolve the full path to a DOCX template."""
name = template_name or self.TEMPLATE_NAME
path = self.templates_dir / name
if not path.exists():
raise FileNotFoundError(f"Template not found: {path}")
return path
def _make_work_dir(self) -> str:
"""Create a temporary working directory for generated files."""
if self._work_dir is None:
self._work_dir = tempfile.mkdtemp(prefix=f"pw_{self.SERVICE_SLUG}_")
return self._work_dir
def _output_filename(self, order_number: str, ext: str = "docx") -> str:
"""Generate a consistent output filename."""
date_str = datetime.now().strftime("%Y%m%d")
slug = self.SERVICE_SLUG.replace("-", "_")
return f"{slug}_{order_number}_{date_str}.{ext}"
# ------------------------------------------------------------------ #
# Template filling (python-docx)
# ------------------------------------------------------------------ #
def _fill_template(
self,
template_path: Path,
variables: dict[str, str],
output_path: str,
) -> str:
"""Open a DOCX template, replace {{variable}} placeholders, and save.
Supports replacement in paragraphs and table cells.
Returns the output path.
"""
from docx import Document
doc = Document(str(template_path))
def _replace_in_paragraph(paragraph: Any) -> None:
for key, value in variables.items():
placeholder = "{{" + key + "}}"
if placeholder in paragraph.text:
# Preserve formatting: replace in runs
for run in paragraph.runs:
if placeholder in run.text:
run.text = run.text.replace(placeholder, value)
for paragraph in doc.paragraphs:
_replace_in_paragraph(paragraph)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
_replace_in_paragraph(paragraph)
doc.save(output_path)
logger.info("Filled template → %s", output_path)
return output_path
def _add_sections_to_doc(
self,
doc_path: str,
sections: dict[str, str],
) -> str:
"""Append named sections (from LLM output) to an existing DOCX.
Each section gets a heading followed by the generated content.
Returns the (modified) doc_path.
"""
from docx import Document
from docx.shared import Pt
doc = Document(doc_path)
for section_name, content in sections.items():
heading = section_name.replace("_", " ").title()
doc.add_heading(heading, level=2)
for paragraph_text in content.split("\n\n"):
paragraph_text = paragraph_text.strip()
if not paragraph_text:
continue
p = doc.add_paragraph(paragraph_text)
for run in p.runs:
run.font.size = Pt(11)
doc.save(doc_path)
return doc_path
# ------------------------------------------------------------------ #
# PDF conversion (LibreOffice headless)
# ------------------------------------------------------------------ #
def _convert_to_pdf(self, docx_path: str) -> str:
"""Convert a DOCX file to PDF using LibreOffice.
Returns the path to the generated PDF file.
"""
output_dir = str(Path(docx_path).parent)
result = subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
output_dir,
docx_path,
],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
raise RuntimeError(
f"LibreOffice conversion failed: {result.stderr}"
)
pdf_path = str(Path(docx_path).with_suffix(".pdf"))
if not Path(pdf_path).exists():
raise FileNotFoundError(f"PDF not generated: {pdf_path}")
logger.info("Converted %s%s", docx_path, pdf_path)
return pdf_path
# ------------------------------------------------------------------ #
# Entity intake request — pause order until client provides entity data
# ------------------------------------------------------------------ #
def _request_entity_intake(self, order_data: dict) -> None:
"""Pause the order and email the client to complete entity intake.
Called when an order is dispatched but the entity data (company name,
FRN, officer, etc.) is missing. Sends the client to the intake wizard.
For batch orders, only the first handler to call this sends the email;
subsequent handlers for the same batch skip the email (but still pause
their own order).
"""
import psycopg2
order_number = order_data.get("name", "")
customer_email = order_data.get("customer_email", "")
customer_name = order_data.get("customer_name", "")
frn = (order_data.get("intake_data") or {}).get("frn", "")
batch_id = order_data.get("batch_id")
# Update order status to pending intake
try:
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
cur.execute(
"""UPDATE compliance_orders
SET payment_status = 'pending_intake',
notes = COALESCE(notes, '') || %s
WHERE order_number = %s""",
[f"\nPaused: entity data missing ({datetime.now().isoformat()})", order_number],
)
conn.commit()
cur.close()
conn.close()
except Exception as exc:
logger.warning("Could not update order status: %s", exc)
# For batch orders, only the first order (lowest order_number) sends the
# intake email. Others just pause silently. This avoids the race condition
# where concurrent handlers all check DB before any has committed.
if batch_id and customer_email:
try:
conn2 = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur2 = conn2.cursor()
cur2.execute(
"""SELECT MIN(order_number) FROM compliance_orders
WHERE batch_id = %s""",
(batch_id,),
)
first_order = cur2.fetchone()[0]
cur2.close()
conn2.close()
if first_order and order_number != first_order:
logger.info(
"Skipping intake email for %s — batch %s will send from %s",
order_number, batch_id, first_order,
)
return
except Exception:
pass # If check fails, send the email anyway
# Email the client
if customer_email:
try:
import PyJWT as pyjwt
except ImportError:
try:
import jwt as pyjwt
except ImportError:
logger.warning("No JWT library available — cannot send intake link")
return
secret = os.environ.get("CUSTOMER_JWT_SECRET", "changeme")
domain = os.environ.get("DOMAIN", "performancewest.net")
token = pyjwt.encode(
{"order_id": order_number, "order_type": "compliance", "email": customer_email},
secret, algorithm="HS256",
)
# For batch orders, build a generic intake email listing all services;
# for single orders, link directly to the service intake page.
if batch_id:
# Get all service names in this batch
batch_services = []
try:
conn3 = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur3 = conn3.cursor()
cur3.execute(
"SELECT service_name FROM compliance_orders WHERE batch_id = %s ORDER BY order_number",
(batch_id,),
)
batch_services = [r[0] for r in cur3.fetchall()]
cur3.close()
conn3.close()
except Exception:
batch_services = [self.SERVICE_NAME]
services_html = "".join(f"<li>{s}</li>" for s in batch_services)
# Link to the first service's intake page
intake_url = f"https://{domain}/order/{self.SERVICE_SLUG}?token={token}&frn={frn}"
service_label = "FCC Compliance Services"
extra_text = (
f"<p>Your order includes:</p><ul style='margin:8px 0 16px 20px'>{services_html}</ul>"
f"<p>We'll start with the first filing — the intake form collects information "
f"needed for all services in your order.</p>"
)
else:
intake_url = f"https://{domain}/order/{self.SERVICE_SLUG}?token={token}&frn={frn}"
service_label = self.SERVICE_NAME
extra_text = ""
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
first_name = customer_name.split(" ")[0] if customer_name else "there"
subject = f"Action Required — Complete your {service_label} intake"
body = (
f"<h2>We need a few more details</h2>"
f"<p>Hi {first_name},</p>"
f"<p>Thank you for your order. To prepare your <strong>{service_label}</strong> "
f"filing, we need some additional information about your company.</p>"
f"{extra_text}"
f"<p>Please click below to complete the intake form — it takes about 2 minutes.</p>"
f"<p><a href='{intake_url}' style='display:inline-block;background:#1e3a5f;color:#fff;"
f"padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600;'>"
f"Complete Intake Form →</a></p>"
f"<p style='font-size:12px;color:#9ca3af;'>Order: {batch_id or order_number}</p>"
)
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", "")
smtp_pass = os.environ.get("SMTP_PASS", "")
smtp_from = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>")
if smtp_user and smtp_pass:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = customer_email
msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info("Entity intake email sent to %s for %s", customer_email, order_number)
except Exception as exc:
logger.warning("Could not send intake email: %s", exc)
# ------------------------------------------------------------------ #
# LLM interaction
# ------------------------------------------------------------------ #
async def _call_llm(
self,
system_prompt: str,
user_prompt: str,
temperature: float | None = None,
max_tokens: int | None = None,
) -> str:
"""Call the LLM API and return the generated text."""
import httpx
headers = {
"Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": LLM_MODEL,
"temperature": temperature or LLM_TEMPERATURE,
"max_tokens": max_tokens or LLM_MAX_TOKENS,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(LLM_API_URL, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def _generate_sections(
self,
system_prompt: str,
sections: list[dict[str, str]],
context: str,
) -> dict[str, str]:
"""Generate multiple document sections via the LLM.
Args:
system_prompt: The service-specific system prompt.
sections: List of dicts with 'name' and 'prompt' keys.
context: Order/customer context to include in each prompt.
Returns:
Dict mapping section names to generated content.
"""
results: dict[str, str] = {}
for section in sections:
user_prompt = (
f"Context:\n{context}\n\n"
f"Section: {section['name']}\n\n"
f"{section['prompt']}"
)
content = await self._call_llm(system_prompt, user_prompt)
results[section["name"]] = content
logger.info(
"Generated section '%s' (%d chars)",
section["name"],
len(content),
)
return results
# ------------------------------------------------------------------ #
# Context extraction
# ------------------------------------------------------------------ #
def _extract_order_context(self, order_data: dict) -> str:
"""Build a text context block from order data for LLM prompts."""
lines = [
f"Order Number: {order_data.get('name', 'N/A')}",
f"Customer: {order_data.get('customer_name', order_data.get('customer', 'N/A'))}",
f"Service: {self.SERVICE_NAME}",
]
# Include custom fields if present
for key in [
"custom_company_size",
"custom_industry",
"custom_state",
"custom_notes",
"custom_intake_data",
]:
val = order_data.get(key)
if val:
label = key.replace("custom_", "").replace("_", " ").title()
lines.append(f"{label}: {val}")
return "\n".join(lines)

View file

@ -0,0 +1,345 @@
"""FCC Broadband Data Collection (BDC) / Form 477 filing handler.
BDC submissions are due June 1 (Dec-31 snapshot) and December 1 (Jun-30
snapshot). Voice-only carriers still use the legacy Form 477 voice
subscription data template.
Flow:
1. Produce a summary packet from the carrier's availability data (pulled
from the order's ``intake_data``). We reuse ``BaseServiceHandler._fill_template``
with a lightweight DOCX template if present; otherwise we emit a
plain text summary (the packet is informational the portal
submission is the real filing).
2. Launch undetected browser, log into the BDC portal at
https://bdc.fcc.gov/, navigate to the active filing window, upload
the availability CSV (from intake_data or a computed skeleton), and
submit.
3. Capture confirmation and persist.
Idempotency: if the carrier already has a BDC filing within the last 180
days, skip the portal submission.
"""
from __future__ import annotations
import csv
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
from .telecom import filing_state
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay
logger = logging.getLogger(__name__)
BDC_URL = os.environ.get("FCC_BDC_URL", "https://bdc.fcc.gov/")
BDC_STORAGE_STATE = os.environ.get(
"FCC_BDC_STORAGE_STATE", "/app/data/fcc_bdc_session.json"
)
class BDCFilingHandler(BaseServiceHandler):
"""Unified BDC filing handler.
Drives all three catalog slugs:
* ``bdc-filing`` both broadband deployment + voice subscription
* ``bdc-broadband`` broadband deployment only
* ``bdc-voice`` voice subscription only (formerly Form 477 Voice)
Mode is derived from ``intake_data.bdc_mode`` first (explicit override),
otherwise from the service slug on the order. The portal submission
only fills the blocks that match the mode; skipped blocks aren't
overwritten on the BDC portal side.
"""
SERVICE_SLUG = "bdc-filing"
SERVICE_NAME = "BDC Filing (Broadband + Voice)"
REQUIRES_LLM = False
# Slug → default mode lookup.
_SLUG_TO_MODE = {
"bdc-filing": "both",
"bdc-broadband": "broadband",
"bdc-voice": "voice",
}
def _resolve_mode(self, order_data: dict) -> str:
"""broadband | voice | both — explicit intake override wins."""
explicit = (order_data.get("intake_data") or {}).get("bdc_mode")
if explicit in ("broadband", "voice", "both"):
return explicit
slug = (order_data.get("service_slug")
or order_data.get("custom_order_type")
or self.SERVICE_SLUG)
return self._SLUG_TO_MODE.get(slug, "both")
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
intake = order_data.get("intake_data") or {}
mode = self._resolve_mode(order_data)
logger.info("BDCFilingHandler: order %s mode=%s", order_number, mode)
generated: list[str] = []
# ── 1. Build availability CSV from intake_data (broadband only) ──
csv_path = None
if mode in ("broadband", "both"):
csv_path = self._build_availability_csv(
order_number, entity, intake, work_dir
)
if csv_path:
generated.append(csv_path)
# Summary text for the customer (always — it's the deliverable).
summary_path = self._build_summary_text(
order_number, entity, intake, work_dir, mode=mode,
)
if summary_path:
generated.append(summary_path)
# ── 2. Idempotency ──────────────────────────────────────────────
if entity_id and filing_state.already_filed(entity_id, "bdc"):
logger.info(
"BDCFilingHandler: BDC already filed for entity %s within 180 days",
entity_id,
)
return generated
# ── 2a. Auto-filing toggle ──────────────────────────────────────
decision = check_auto_filing(order_data)
if not decision.may_submit:
logger.info(
"BDCFilingHandler: %s — staging for admin review (order=%s)",
decision.reason, order_number,
)
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in generated],
admin_email=decision.admin_email,
summary=f"BDC availability CSV ready. Submit via {BDC_URL}.",
)
return generated
# ── 3. Portal submission ────────────────────────────────────────
confirmation_path, confirmation_number = await self._submit_to_bdc(
order_number=order_number,
entity=entity,
csv_path=csv_path,
work_dir=work_dir,
mode=mode,
intake=intake,
)
if confirmation_path:
generated.append(confirmation_path)
if entity_id and confirmation_number:
filing_state.record_bdc_filing(entity_id, confirmation_number)
return generated
# ------------------------------------------------------------------ #
# CSV / summary
# ------------------------------------------------------------------ #
def _build_availability_csv(
self, order_number: str, entity: dict, intake: dict, work_dir: str
) -> str | None:
rows = intake.get("availability_rows") or []
if not rows:
logger.warning(
"BDCFilingHandler: no availability_rows in intake_data for %s"
"emitting empty template",
order_number,
)
rows = []
csv_path = os.path.join(
work_dir, f"bdc_availability_{order_number}.csv"
)
fieldnames = [
"frn",
"provider_id",
"brand_name",
"location_id",
"technology",
"max_advertised_download_speed",
"max_advertised_upload_speed",
"low_latency",
"business_residential_code",
]
with open(csv_path, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
out: dict = {k: row.get(k, "") for k in fieldnames}
out.setdefault("frn", entity.get("frn", ""))
out.setdefault("brand_name", entity.get("dba_name") or entity.get("legal_name", ""))
writer.writerow(out)
return csv_path
def _build_summary_text(
self, order_number: str, entity: dict, intake: dict, work_dir: str,
*, mode: str = "both",
) -> str | None:
txt_path = os.path.join(work_dir, f"bdc_summary_{order_number}.txt")
mode_label = {
"broadband": "Broadband deployment only",
"voice": "Voice subscription only (formerly Form 477 Voice)",
"both": "Broadband deployment + Voice subscription",
}.get(mode, mode)
lines = [
f"FCC BDC Filing Summary",
f"Order: {order_number}",
f"Carrier: {entity.get('legal_name', '')}",
f"FRN: {entity.get('frn', '')}",
f"Scope: {mode_label}",
f"Filing Date: {datetime.now().strftime('%Y-%m-%d')}",
"",
]
if mode in ("broadband", "both"):
lines.append(f"Availability rows submitted: {len(intake.get('availability_rows') or [])}")
if mode in ("voice", "both"):
lines.append(f"Voice subscribers (formerly Form 477): {intake.get('voice_subscribers', 0)}")
lines.append(f"Filing window snapshot: {intake.get('snapshot_date', 'unspecified')}")
with open(txt_path, "w") as fh:
fh.write("\n".join(lines) + "\n")
return txt_path
# ------------------------------------------------------------------ #
# BDC portal submission
# ------------------------------------------------------------------ #
async def _submit_to_bdc(
self,
*,
order_number: str,
entity: dict,
csv_path: str | None,
work_dir: str,
mode: str = "both",
intake: dict | None = None,
) -> tuple[str | None, str]:
intake = intake or {}
frn = entity.get("frn", "").strip()
needs_csv = mode in ("broadband", "both")
if not frn:
self._create_admin_todo(
order_number,
"BDC filing requires an FRN. Missing prerequisite — "
"order a CORES/FRN registration first or capture the FRN on the entity.",
)
return None, ""
if needs_csv and not csv_path:
self._create_admin_todo(
order_number,
f"BDC {mode} filing requires an availability CSV but none was generated. "
"Review the carrier's intake_data.availability_rows.",
)
return None, ""
storage_state = BDC_STORAGE_STATE if os.path.exists(BDC_STORAGE_STATE) else None
confirmation_path = os.path.join(
work_dir, f"bdc_confirmation_{order_number}.pdf"
)
confirmation_number = ""
try:
async with undetected_browser(
headless=True, storage_state=storage_state,
) as (ctx, page):
await page.goto(BDC_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
if "login" in page.url.lower():
self._create_admin_todo(
order_number,
f"BDC portal required login for FRN {frn}. Run the FCC Access "
f"Helper to authorize our account, export session to "
f"{BDC_STORAGE_STATE}, then re-dispatch {order_number}.",
)
return None, ""
# Start a new filing for the active window.
await page.click("text=File Data")
await human_delay()
# Select the active filing window (most recent).
await page.click("text=Current Filing Window")
await human_delay()
# Broadband deployment block — availability CSV.
if mode in ("broadband", "both") and csv_path:
await page.set_input_files(
'input[type="file"][name="availability_data"]',
csv_path,
)
await human_delay(2.0, 4.0)
# Voice subscriber block (formerly Form 477 Voice). Intake
# provides voice_subscribers as an integer; we only fill when
# the mode includes voice.
if mode in ("voice", "both"):
voice_subs = int(
intake.get("voice_subscribers")
or entity.get("voice_subscribers")
or 0
)
if voice_subs:
await page.fill('input[name="voice_subscribers"]', str(voice_subs))
# Certify + submit.
await page.check('input[name="officer_certification"]')
await page.click('button:has-text("Submit Filing")')
await page.wait_for_selector("text=Confirmation", timeout=120000)
body = await page.locator("body").inner_text()
for line in body.splitlines():
if "Confirmation" in line:
parts = line.split(":", 1)
if len(parts) == 2 and parts[1].strip():
confirmation_number = parts[1].strip()
break
await page.pdf(path=confirmation_path, format="Letter")
logger.info(
"BDCFilingHandler: filed FRN %s, confirmation %s",
frn, confirmation_number,
)
return confirmation_path, confirmation_number
except Exception as exc:
logger.exception("BDCFilingHandler: portal submission failed: %s", exc)
self._create_admin_todo(
order_number,
f"BDC submission failed for FRN {frn}: {exc}. Packet is in MinIO; "
"file manually at https://bdc.fcc.gov/.",
)
return None, ""
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

View file

@ -0,0 +1,72 @@
"""Data Breach Response Plan handler (template-based).
Generates a data breach incident response plan by filling a comprehensive
DOCX template with customer-specific information. Does not require LLM
generation uses pre-written plan sections with variable substitution.
"""
from __future__ import annotations
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
class BreachResponseHandler(BaseServiceHandler):
SERVICE_SLUG = "breach-response"
SERVICE_NAME = "Data Breach Response Plan"
TEMPLATE_NAME = "breach_response_template.docx"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
now = datetime.now()
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": now.strftime("%B %d, %Y"),
"effective_date": now.strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
# Company details
"company_name": order_data.get("custom_company_legal_name", order_data.get("customer_name", "")),
"company_address": order_data.get("custom_company_address", "[ADDRESS]"),
"company_phone": order_data.get("custom_contact_phone", "[PHONE]"),
"company_email": order_data.get("custom_contact_email", "[EMAIL]"),
# Incident response team
"irt_lead": order_data.get("custom_irt_lead", "[IRT LEAD]"),
"irt_lead_title": order_data.get("custom_irt_lead_title", "[TITLE]"),
"irt_lead_phone": order_data.get("custom_irt_lead_phone", "[PHONE]"),
"irt_lead_email": order_data.get("custom_irt_lead_email", "[EMAIL]"),
"legal_counsel": order_data.get("custom_legal_counsel", "[LEGAL COUNSEL]"),
"legal_counsel_phone": order_data.get("custom_legal_counsel_phone", "[PHONE]"),
"it_security_lead": order_data.get("custom_it_security_lead", "[IT SECURITY LEAD]"),
"communications_lead": order_data.get("custom_communications_lead", "[COMMUNICATIONS LEAD]"),
"hr_lead": order_data.get("custom_hr_lead", "[HR LEAD]"),
# External contacts
"cyber_insurance_carrier": order_data.get("custom_cyber_insurance_carrier", "[CARRIER]"),
"cyber_insurance_policy": order_data.get("custom_cyber_insurance_policy", "[POLICY #]"),
"forensics_vendor": order_data.get("custom_forensics_vendor", "[FORENSICS VENDOR]"),
"pr_firm": order_data.get("custom_pr_firm", "[PR FIRM]"),
# Jurisdiction & regulation
"state": order_data.get("custom_state", "California"),
"notification_deadline_days": order_data.get("custom_notification_deadline", "30"),
"ag_notification_threshold": order_data.get("custom_ag_threshold", "500"),
"applicable_laws": order_data.get("custom_applicable_breach_laws", "Cal. Civ. Code § 1798.82"),
# Infrastructure
"total_records": order_data.get("custom_total_records", "[TOTAL RECORDS]"),
"data_types": order_data.get("custom_sensitive_data_types", "SSN, financial account numbers, health information"),
"primary_systems": order_data.get("custom_primary_systems", "[PRIMARY SYSTEMS]"),
}
self._fill_template(template_path, variables, docx_path)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,370 @@
"""CALEA SSI Plan handler.
No portal submission SSI plans are held internally. We produce a
carrier-specific signed DOCX + PDF, persist the generation timestamp
and next-review date on the entity, and schedule an annual review
reminder in the Compliance Calendar.
Auto-filing toggle doesn't apply (no filing is made).
"""
from __future__ import annotations
import logging
import os
from datetime import date, datetime, timedelta
from typing import Optional
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
class CALEASSIHandler(BaseServiceHandler):
SERVICE_SLUG = "calea-ssi"
SERVICE_NAME = "CALEA SSI Plan"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
intake = order_data.get("intake_data") or {}
entity_id = entity.get("id")
calea_intake = intake.get("calea_ssi") or {}
# Guard: pause if critical CALEA intake fields are missing.
# The SSI plan requires a 24-hour law enforcement contact and
# network infrastructure details. Without these the document
# would have [TO BE POPULATED] placeholders.
le_contact = calea_intake.get("law_enforcement_contact") or {}
missing_fields = []
if not le_contact.get("name"):
missing_fields.append("law enforcement contact name")
if not le_contact.get("phone"):
missing_fields.append("law enforcement contact 24-hour phone")
if not le_contact.get("email"):
missing_fields.append("law enforcement contact email")
if not calea_intake.get("network_infrastructure_summary"):
missing_fields.append("network infrastructure summary")
if not calea_intake.get("interception_support_method"):
missing_fields.append("interception support method")
if missing_fields and not self._is_private_line_only(entity):
logger.info(
"CALEASSIHandler: missing intake fields for %s: %s — creating admin todo",
order_number, ", ".join(missing_fields),
)
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource("ToDo", {
"description": (
f"[calea-ssi] {order_number}\n\n"
f"CALEA SSI Plan cannot be generated — missing intake data.\n"
f"Missing: {', '.join(missing_fields)}\n\n"
f"Entity: {entity.get('legal_name', 'unknown')}\n"
f"Send the client the CALEA questionnaire to collect:\n"
f" - 24-hour law enforcement contact (name, phone, email)\n"
f" - Network infrastructure summary\n"
f" - Interception support method (TTP vendor or self-provision)\n\n"
f"Once collected, update intake_data.calea_ssi and re-dispatch."
),
"priority": "High",
"role": "Accounting Advisor",
})
except Exception as exc:
logger.error("Could not create CALEA admin ToDo: %s", exc)
return []
generated: list[str] = []
date_str = datetime.now().strftime("%Y%m%d")
docx_path = os.path.join(
work_dir, f"calea_ssi_{order_number}_{date_str}.docx",
)
# Private-line-only filers are typically CALEA-exempt under
# 47 CFR 1.20003 scope. Produce a 1-page exemption memo inline.
if self._is_private_line_only(entity):
logger.info(
"CALEASSIHandler: entity %s is private-line only; "
"producing CALEA exemption memo",
entity.get("id"),
)
memo_path = self._write_private_line_exemption_memo(
docx_path, entity
)
if memo_path:
generated.append(memo_path)
try:
generated.append(self._convert_to_pdf(memo_path))
except Exception as exc:
logger.warning(
"CALEA exemption memo PDF conversion failed: %s", exc
)
else:
variant_fn = self._pick_template_generator(entity)
common_kwargs = dict(
output_path=docx_path,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
law_enforcement_contact=calea_intake.get("law_enforcement_contact"),
cpni_protection_officer=calea_intake.get("cpni_protection_officer"),
network_infrastructure_summary=calea_intake.get(
"network_infrastructure_summary", ""
),
interception_support_method=calea_intake.get(
"interception_support_method", ""
),
reporting_year=entity.get("reporting_year", 0),
signatory_name=entity.get("ceo_name")
or entity.get("contact_name", ""),
signatory_title=entity.get(
"ceo_title", "Chief Executive Officer"
),
)
if variant_fn is not None:
logger.info(
"CALEASSIHandler: using variant generator %s for entity %s",
getattr(variant_fn, "__name__", "<unknown>"),
entity.get("id"),
)
result = variant_fn(**common_kwargs)
else:
from scripts.document_gen.templates.calea_ssi_generator import (
generate_calea_ssi_plan,
)
result = generate_calea_ssi_plan(
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
law_enforcement_contact=calea_intake.get("law_enforcement_contact"),
cpni_protection_officer=calea_intake.get("cpni_protection_officer"),
network_infrastructure_summary=calea_intake.get(
"network_infrastructure_summary", ""
),
interception_support_method=calea_intake.get(
"interception_support_method", ""
),
is_interconnected_voip=entity.get("carrier_category", "") in (
"interconnected_voip", "non_interconnected_voip",
),
is_wholesale=bool(entity.get("is_wholesale")),
has_retail_customers=not bool(entity.get("is_wholesale")),
signatory_name=entity.get("ceo_name")
or entity.get("contact_name", ""),
signatory_title=entity.get(
"ceo_title", "Chief Executive Officer"
),
output_path=docx_path,
)
if result:
generated.append(result)
try:
generated.append(self._convert_to_pdf(result))
except Exception as exc:
logger.warning("CALEA SSI PDF conversion failed: %s", exc)
# Persist + schedule annual review
if entity_id:
self._persist_calea_state(
entity_id,
reviewer_name=calea_intake.get("reviewer_name", "Justin Hannah"),
)
self._schedule_annual_review(order_number, entity)
return generated
# ------------------------------------------------------------------ #
# Variant selection + private-line exemption
# ------------------------------------------------------------------ #
def _pick_template_generator(self, entity: dict):
"""
Resolve the CALEA variant generator for the entity's Line 105
primary + infra_type. Returns a callable on match, else ``None``
(caller falls back to the generic generate_calea_ssi_plan).
"""
primary = entity.get("line_105_primary") or entity.get(
"filer_type", "voip_interconnected"
)
cats = entity.get("line_105_categories") or []
entry = next((c for c in cats if c.get("id") == primary), {})
infra = entry.get("infra_type", "facilities")
mapping = {
("clec", "facilities"): "calea_clec_ss7_generator",
("clec", "reseller"): "calea_clec_ss7_generator",
("ixc", "facilities"): "calea_ixc_ss7_generator",
("ixc", "reseller"): "calea_ixc_ss7_generator",
("wireless", "facilities"): "calea_wireless_generator",
("wireless", "mvno"): "calea_wireless_mvno_generator",
("satellite", "facilities"): "calea_satellite_generator",
("audio_bridging", "facilities"): "calea_audio_bridge_generator",
}
module_name = mapping.get((primary, infra))
if not module_name:
return None
try:
import importlib
mod = importlib.import_module(
f"scripts.document_gen.templates.{module_name}"
)
fn_name = "generate_" + module_name.replace("_generator", "")
return getattr(mod, fn_name)
except Exception as exc:
logger.warning(
"CALEASSIHandler: failed to load CALEA variant %s: %s",
module_name, exc,
)
return None
def _is_private_line_only(self, entity: dict) -> bool:
"""
Return True if the entity's Line 105 primary is private_line AND
it carries no other CALEA-bearing categories. Private-line-only
carriers are generally outside the CALEA covered-entity
definition produce a 1-page exemption memo instead of a full
SSI plan.
"""
primary = entity.get("line_105_primary")
if primary != "private_line":
return False
cats = entity.get("line_105_categories") or []
calea_bearing = {
"clec", "ixc", "wireless", "satellite",
"audio_bridging", "voip_interconnected",
}
for c in cats:
cid = c.get("id")
if cid and cid != "private_line" and cid in calea_bearing:
return False
return True
def _write_private_line_exemption_memo(
self, output_path: str, entity: dict
) -> Optional[str]:
"""
Write a 1-page CALEA exemption memo for a private-line-only
carrier, citing 47 CFR § 1.20003 scope. Returns the output
path, or None on failure.
"""
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
logger.error("python-docx not installed — exemption memo skipped")
return None
NAVY = RGBColor(0x1A, 0x27, 0x44)
today = date.today().strftime("%B %d, %Y")
name = entity.get("legal_name", "")
frn = entity.get("frn", "")
doc = Document()
for s in doc.sections:
s.top_margin = Inches(1); s.bottom_margin = Inches(1)
s.left_margin = Inches(1.25); s.right_margin = Inches(1.25)
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = tp.add_run("CALEA Exemption Memo")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sp.add_run("Private Line / BDS — Not a Covered Entity under CALEA")
sr.font.size = Pt(11); sr.italic = True
sp.paragraph_format.space_after = Pt(16)
def _p(text: str, bold: bool = False) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
_p(f"Carrier: {name}", bold=True)
if frn:
_p(f"FRN: {frn}")
_p(f"Date: {today}")
_p("")
_p(
"The Communications Assistance for Law Enforcement Act (CALEA), "
"47 U.S.C. \u00a7\u00a7 1001\u20131010, and the Commission's "
"implementing rules at 47 CFR Part 1 Subpart Z (including 47 CFR "
"\u00a7 1.20003) apply to 'telecommunications carriers' within "
"the meaning of 47 U.S.C. \u00a7 1001(8). Dedicated point-to-"
"point private line and/or Business Data Service (BDS) offerings "
"that do not provide switched voice, interconnected VoIP, or "
"other CALEA-covered transmission are generally outside the "
"scope of the statute and the rule."
)
_p(
f"Based on {name}'s Line 105 classification and service "
f"description, {name} provides only private-line / BDS circuits "
f"and does not operate a switched telecommunications service "
f"subject to CALEA. Accordingly, {name} maintains no separate "
f"SSI Plan at this time."
)
_p(
f"{name} will re-evaluate its CALEA status upon any addition of "
f"switched voice, interconnected VoIP, or other CALEA-covered "
f"service, and will prepare and maintain an SSI Plan prior to "
f"the initiation of such service."
)
_p(
"This memo is retained in the corporate compliance file and is "
"available on request to the Commission or the Department of "
"Justice."
)
out = os.fspath(output_path)
doc.save(out)
return out
def _persist_calea_state(self, entity_id: int, reviewer_name: str) -> None:
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
next_review = date.today() + timedelta(days=365)
cur.execute(
"""
UPDATE telecom_entities SET
calea_ssi_generated_at = NOW(),
calea_ssi_reviewer_name = %s,
calea_ssi_next_review_date = %s
WHERE id = %s
""",
(reviewer_name, next_review, entity_id),
)
conn.commit()
conn.close()
except Exception as exc:
logger.warning("Could not persist CALEA state on %s: %s", entity_id, exc)
def _schedule_annual_review(self, order_number: str, entity: dict) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
next_review = date.today() + timedelta(days=365)
ERPNextClient().create_resource(
"Compliance Calendar",
{
"entity_name": entity.get("legal_name", ""),
"order_reference": order_number,
"compliance_type": "CALEA SSI Annual Review",
"description": (
"Annual review of the CALEA System Security and "
"Integrity Plan (47 USC § 229 / 47 CFR § 1.20003). "
"Verify designated LE contact, infrastructure changes, "
"and CPNI officer are still accurate."
),
"due_date": next_review.strftime("%Y-%m-%d"),
"recurring": 1,
"recurrence_period":"Yearly",
"status": "Upcoming",
},
)
except Exception as exc:
logger.warning("Could not schedule CALEA annual review: %s", exc)

View file

@ -0,0 +1,133 @@
"""Marketing Campaign Compliance Review handler (LLM-based).
Reviews marketing campaigns for compliance with CAN-SPAM, TCPA, FTC
Endorsement Guides, state advertising laws, and industry-specific
marketing regulations.
"""
from __future__ import annotations
import os
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating a Marketing Campaign Compliance Review report.
RULES:
- Write in professional, clear business English
- Reference CAN-SPAM Act (15 U.S.C. § 7701), TCPA, FTC Act § 5,
FTC Endorsement Guides (16 CFR Part 255), and state consumer protection laws
- Never provide legal advice use "we recommend" not "you must"
- For each finding: what was found, regulation, risk level (Low/Medium/High/Critical), remediation
- Consider: email marketing, SMS marketing, social media advertising,
influencer partnerships, testimonials, sweepstakes/contests, and pricing claims
- Assess truthfulness, substantiation, disclosures, and consent requirements
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the marketing campaign "
"compliance review. Include scope (channels and campaigns reviewed), "
"overall compliance posture, and highest-risk findings."
),
},
{
"name": "email_marketing",
"prompt": (
"Review email marketing compliance under CAN-SPAM. Assess: "
"from/reply-to accuracy, subject line deceptiveness, commercial "
"message identification, physical address inclusion, opt-out "
"mechanism (must process within 10 business days), opt-out "
"honoring, third-party email obligations, and transactional "
"vs. commercial message classification."
),
},
{
"name": "sms_and_calling",
"prompt": (
"Review SMS/text and calling campaign compliance under TCPA "
"and state laws. Assess: prior express written consent for "
"marketing texts, autodialer usage, opt-out mechanisms (STOP "
"keyword), frequency disclosures, and compliance with CTIA "
"short code monitoring handbook guidelines."
),
},
{
"name": "advertising_claims",
"prompt": (
"Review advertising claims for FTC Act § 5 compliance. Assess: "
"truthfulness of claims, adequate substantiation, clear and "
"conspicuous disclosures, comparative advertising accuracy, "
"environmental/green marketing claims (FTC Green Guides), "
"health/safety claims, and 'free' offer compliance."
),
},
{
"name": "endorsements_and_social",
"prompt": (
"Review endorsement and social media marketing under FTC "
"Endorsement Guides. Assess: material connection disclosures, "
"influencer/ambassador agreements, employee social media "
"guidelines, testimonial substantiation, review solicitation "
"practices, and platform-specific compliance (FTC vs. platform "
"rules)."
),
},
{
"name": "promotions_and_contests",
"prompt": (
"Review sweepstakes, contests, and promotional offers. Assess: "
"official rules completeness, no-purchase-necessary compliance, "
"void-where-prohibited disclosures, prize descriptions, odds "
"disclosures, state registration requirements, and bonding "
"requirements where applicable."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized remediation plan for all findings. "
"For each item: finding reference, risk level, recommended action, "
"responsible party, timeline. Distinguish between in-market "
"corrections (urgent) and process improvements (ongoing)."
),
},
]
class CampaignReviewHandler(BaseServiceHandler):
SERVICE_SLUG = "campaign-review"
SERVICE_NAME = "Marketing Campaign Compliance Review"
TEMPLATE_NAME = "campaign_review_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
self._add_sections_to_doc(docx_path, sections)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,132 @@
"""CCPA/CPRA Compliance Audit handler (LLM-based).
Generates a California Consumer Privacy Act / California Privacy Rights Act
compliance audit report. Also used for the "data-mapping" service which
shares similar analysis.
"""
from __future__ import annotations
import os
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating a CCPA/CPRA Privacy Compliance Audit report.
RULES:
- Write in professional, clear business English
- Cite specific CCPA/CPRA sections (Cal. Civ. Code §§ 1798.100-1798.199.100)
- Reference CPRA amendments effective January 1, 2023
- Never provide legal advice use "we recommend" not "you must"
- For each finding: what was found, regulation, risk level (Low/Medium/High/Critical), remediation
- Consider CCPA applicability thresholds ($25M revenue, 100k consumers, 50% revenue from data)
- Address all consumer rights: know, delete, opt-out, correct, limit use of sensitive PI
- Reference CPPA enforcement guidance where applicable
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the CCPA/CPRA audit findings. "
"Include applicability determination, scope, overall compliance posture, "
"and critical findings."
),
},
{
"name": "data_inventory",
"prompt": (
"Analyze the organization's personal information inventory. Categories "
"of PI collected, sources, purposes, third-party sharing, sensitive PI "
"processing, retention periods, and cross-border transfers. Identify "
"gaps in data mapping documentation."
),
},
{
"name": "consumer_rights",
"prompt": (
"Assess compliance with each CCPA/CPRA consumer right: right to know, "
"right to delete, right to opt-out of sale/sharing, right to correct, "
"right to limit use of sensitive PI, right to non-discrimination. "
"For each: process exists (Y/N), response timeline, verification method, gaps."
),
},
{
"name": "privacy_notices",
"prompt": (
"Review privacy notices and disclosures: at-or-before-collection notice, "
"privacy policy, financial incentive notices, opt-out preference signals "
"compliance (GPC), 'Do Not Sell or Share' link. Assess content completeness "
"and accessibility."
),
},
{
"name": "vendor_management",
"prompt": (
"Review service provider and contractor agreements for CCPA/CPRA "
"compliance. Assess: data processing agreements, flow-down obligations, "
"audit rights, breach notification, and distinction between service "
"providers, contractors, and third parties under CPRA."
),
},
{
"name": "security_measures",
"prompt": (
"Evaluate reasonable security measures under CCPA § 1798.150 (private "
"right of action for data breaches). Review technical and organizational "
"safeguards, breach detection capabilities, and incident response readiness."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized remediation plan. For each finding: reference, "
"risk level, recommended action, responsible party, timeline. Consider "
"CPPA enforcement priorities and the cure period provisions."
),
},
]
class CCPAAuditHandler(BaseServiceHandler):
SERVICE_SLUG = "ccpa-audit"
SERVICE_NAME = "CCPA/CPRA Compliance Audit"
TEMPLATE_NAME = "ccpa_audit_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
# data-mapping uses same handler with a different template
items = order_data.get("items", [])
item_code = items[0].get("item_code", "") if items else ""
if item_code == "data-mapping":
template_name = "data_mapping_template.docx"
else:
template_name = self.TEMPLATE_NAME
template_path = self._get_template_path(template_name)
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
self._add_sections_to_doc(docx_path, sections)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,330 @@
"""CDR Analysis Handler — rolls CDRs for a reporting period into a
signed traffic study PDF+XLSX and returns the files for delivery.
Triggered by a paid `cdr-analysis` service order (or when the admin
re-runs the aggregation after more data lands). Assumes ingestion has
already happened via cdr_ingester this handler only reads `cdr_calls`.
The computation is revenue-first, minutes as the cross-check. If the
profile has `minutes_only_estimation_enabled = TRUE` and no revenue
data, we compute minutes-only and label the study accordingly.
"""
from __future__ import annotations
import logging
import os
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from typing import Optional
import psycopg2
import psycopg2.extras
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
class CDRAnalysisHandler(BaseServiceHandler):
SERVICE_SLUG = "cdr-analysis"
SERVICE_NAME = "CDR Traffic Study"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
intake = order_data.get("intake_data") or {}
# Resolve profile + reporting period
reporting_year = int(intake.get("reporting_year") or datetime.utcnow().year - 1)
reporting_period = intake.get("reporting_period", "ANNUAL")
profile = self._load_profile(entity.get("id"))
if profile is None:
logger.warning(
"CDRAnalysisHandler: no cdr_ingestion_profile for entity %s",
entity.get("id"),
)
return []
# ── Aggregate the classified calls ─────────────────────────
stats = self._aggregate(
profile_id=profile["id"],
year=reporting_year,
period=reporting_period,
minutes_only=profile.get("minutes_only_estimation_enabled", False),
)
# ── Persist the study ──────────────────────────────────────
study_id = self._upsert_study(profile_id=profile["id"],
year=reporting_year,
period=reporting_period,
stats=stats)
# ── Render PDF + XLSX ──────────────────────────────────────
from scripts.document_gen.templates.cdr_traffic_study_generator import (
generate_traffic_study_docx,
generate_traffic_study_xlsx,
)
date_str = datetime.now().strftime("%Y%m%d")
docx_path = os.path.join(
work_dir,
f"traffic_study_{order_number}_{reporting_year}_{reporting_period}_{date_str}.docx",
)
xlsx_path = os.path.join(
work_dir,
f"traffic_study_{order_number}_{reporting_year}_{reporting_period}_{date_str}.xlsx",
)
study_row = dict(stats, reporting_year=reporting_year, reporting_period=reporting_period)
generated: list[str] = []
try:
result_docx = generate_traffic_study_docx(
study=study_row,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
filer_id_499=entity.get("filer_id_499", ""),
output_path=docx_path,
)
if result_docx:
generated.append(result_docx)
try:
generated.append(self._convert_to_pdf(result_docx))
except Exception as exc:
logger.warning("Traffic study PDF conversion failed: %s", exc)
except Exception as exc:
logger.warning("DOCX generation failed: %s", exc)
try:
result_xlsx = generate_traffic_study_xlsx(
study=study_row,
entity_name=entity.get("legal_name", ""),
output_path=xlsx_path,
)
if result_xlsx:
generated.append(result_xlsx)
except Exception as exc:
logger.warning("XLSX generation failed: %s", exc)
# ── Update the traffic_study row with MinIO paths (after upload) ──
# (actual MinIO upload is done by job_server.py after this returns)
logger.info(
"CDRAnalysisHandler: study #%s generated for profile %s (%s %s)",
study_id, profile["id"], reporting_year, reporting_period,
)
return generated
# ------------------------------------------------------------------
# DB helpers
# ------------------------------------------------------------------
def _connect(self):
return psycopg2.connect(os.environ.get("DATABASE_URL", ""))
def _load_profile(self, entity_id: Optional[int]) -> Optional[dict]:
if not entity_id:
return None
try:
conn = self._connect()
except Exception as exc:
logger.warning("CDRAnalysisHandler: PG connect failed: %s", exc)
return None
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT * FROM cdr_ingestion_profiles WHERE telecom_entity_id=%s",
(entity_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
_PERIOD_MONTHS = {
"Q1": (1, 3), "Q2": (4, 6), "Q3": (7, 9), "Q4": (10, 12),
"ANNUAL": (1, 12),
}
def _aggregate(self, profile_id: int, year: int, period: str,
minutes_only: bool) -> dict:
start_m, end_m = self._PERIOD_MONTHS[period]
start_dt = datetime(year, start_m, 1)
end_dt = (
datetime(year + 1, 1, 1) if end_m == 12
else datetime(year, end_m + 1, 1)
)
conn = self._connect()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT jurisdiction,
customer_type,
orig_state_region,
billing_state_region,
COALESCE(SUM(duration_sec), 0) AS total_secs,
COALESCE(SUM(billed_amount_cents), 0) AS total_cents,
COUNT(*) AS call_count
FROM cdr_calls
WHERE profile_id=%s AND start_time >= %s AND start_time < %s
GROUP BY GROUPING SETS (
(jurisdiction),
(customer_type),
(orig_state_region),
(billing_state_region)
)
""",
(profile_id, start_dt, end_dt),
)
rollups = cur.fetchall()
cur.execute(
"""
SELECT COALESCE(SUM(duration_sec), 0) AS total_secs,
COALESCE(SUM(billed_amount_cents), 0) AS total_cents,
COUNT(*) AS total_calls
FROM cdr_calls
WHERE profile_id=%s AND start_time >= %s AND start_time < %s
""",
(profile_id, start_dt, end_dt),
)
totals = cur.fetchone()
finally:
conn.close()
total_secs = totals["total_secs"] or 0
total_cents = totals["total_cents"] or 0
total_calls = totals["total_calls"] or 0
# Prefer revenue denominator unless minutes_only opt-in
use_revenue = total_cents > 0 and not minutes_only
juris_secs: dict[str, int] = defaultdict(int)
juris_cents: dict[str, int] = defaultdict(int)
bucket_secs: dict[str, int] = defaultdict(int)
orig_secs: dict[str, int] = defaultdict(int)
billing_secs: dict[str, int] = defaultdict(int)
for row in rollups:
secs = row["total_secs"] or 0
cents = row["total_cents"] or 0
if row["jurisdiction"] is not None:
juris_secs[row["jurisdiction"]] = secs
juris_cents[row["jurisdiction"]] = cents
if row["customer_type"] is not None:
bucket_secs[row["customer_type"]] = secs
if row["orig_state_region"] is not None:
orig_secs[row["orig_state_region"]] = secs
if row["billing_state_region"] is not None:
billing_secs[row["billing_state_region"]] = secs
def pct_from_revenue(jur: str) -> Optional[float]:
if total_cents <= 0:
return None
return round((juris_cents.get(jur, 0) / total_cents) * 100, 4)
def pct_from_minutes(jur: str) -> Optional[float]:
if total_secs <= 0:
return None
return round((juris_secs.get(jur, 0) / total_secs) * 100, 4)
def region_pcts(src: dict[str, int]) -> dict[str, float]:
if total_secs <= 0:
return {}
return {
name: round((secs / total_secs) * 100, 4)
for name, secs in src.items() if name
}
return {
"total_calls": total_calls,
"total_minutes": int(total_secs / 60),
"total_revenue_cents": total_cents,
"interstate_pct": pct_from_revenue("interstate") if use_revenue else None,
"intrastate_pct": pct_from_revenue("intrastate") if use_revenue else None,
"international_pct": pct_from_revenue("international") if use_revenue else None,
"indeterminate_pct": pct_from_revenue("indeterminate") if use_revenue else None,
"interstate_pct_minutes": pct_from_minutes("interstate"),
"intrastate_pct_minutes": pct_from_minutes("intrastate"),
"international_pct_minutes": pct_from_minutes("international"),
"indeterminate_pct_minutes": pct_from_minutes("indeterminate"),
"wholesale_minutes": bucket_secs.get("wholesale", 0),
"retail_minutes": bucket_secs.get("retail", 0),
"orig_state_regions_json": region_pcts(orig_secs),
"billing_state_regions_json": region_pcts(billing_secs),
"methodology": (
"Percentages computed from per-call billed revenue; minutes "
"figures shown as cross-check."
if use_revenue else
"Percentages computed from billed minutes — per-call revenue "
"not available; profile has minutes_only_estimation_enabled = TRUE."
),
}
def _upsert_study(self, *, profile_id: int, year: int, period: str,
stats: dict) -> int:
conn = self._connect()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO cdr_traffic_studies (
profile_id, reporting_year, reporting_period,
total_calls, total_minutes, total_revenue_cents,
interstate_pct, intrastate_pct, international_pct, indeterminate_pct,
interstate_pct_minutes, intrastate_pct_minutes,
international_pct_minutes, indeterminate_pct_minutes,
wholesale_minutes, retail_minutes,
orig_state_regions_json, billing_state_regions_json,
methodology, generated_at
) VALUES (
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s,
%s::jsonb, %s::jsonb,
%s, NOW()
)
ON CONFLICT (profile_id, reporting_year, reporting_period)
DO UPDATE SET
total_calls=EXCLUDED.total_calls,
total_minutes=EXCLUDED.total_minutes,
total_revenue_cents=EXCLUDED.total_revenue_cents,
interstate_pct=EXCLUDED.interstate_pct,
intrastate_pct=EXCLUDED.intrastate_pct,
international_pct=EXCLUDED.international_pct,
indeterminate_pct=EXCLUDED.indeterminate_pct,
interstate_pct_minutes=EXCLUDED.interstate_pct_minutes,
intrastate_pct_minutes=EXCLUDED.intrastate_pct_minutes,
international_pct_minutes=EXCLUDED.international_pct_minutes,
indeterminate_pct_minutes=EXCLUDED.indeterminate_pct_minutes,
wholesale_minutes=EXCLUDED.wholesale_minutes,
retail_minutes=EXCLUDED.retail_minutes,
orig_state_regions_json=EXCLUDED.orig_state_regions_json,
billing_state_regions_json=EXCLUDED.billing_state_regions_json,
methodology=EXCLUDED.methodology,
generated_at=NOW()
RETURNING id
""",
(
profile_id, year, period,
stats["total_calls"], stats["total_minutes"], stats["total_revenue_cents"],
stats["interstate_pct"], stats["intrastate_pct"],
stats["international_pct"], stats["indeterminate_pct"],
stats["interstate_pct_minutes"], stats["intrastate_pct_minutes"],
stats["international_pct_minutes"], stats["indeterminate_pct_minutes"],
stats["wholesale_minutes"], stats["retail_minutes"],
psycopg2.extras.Json(stats["orig_state_regions_json"]),
psycopg2.extras.Json(stats["billing_state_regions_json"]),
stats["methodology"],
),
)
new_id = cur.fetchone()[0]
conn.commit()
return new_id
finally:
conn.close()

View file

@ -0,0 +1,134 @@
"""CDR Storage Tier handler.
Storage tier purchases (tier1/2/3) are not filings they're subscription
allocations that bump the customer's `cdr_ingestion_profiles.storage_plan`
quota. No Playwright, no external portal, no artifacts generated.
Tiers correspond to (storage bytes, classified calls) caps:
tier1: 50 GB / 50M calls
tier2: 250 GB / 250M calls
tier3: 1,000 GB / 1,000M calls
A new purchase replaces the existing tier (doesn't stack). Subsequent
ingestion enforces the new cap via `scripts/workers/cdr_ingester.py`.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import psycopg2
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
class CDRStorageTierHandler(BaseServiceHandler):
SERVICE_SLUG = "cdr-storage-tier" # set per subclass
SERVICE_NAME = "CDR Storage Tier"
REQUIRES_LLM = False
TIER_LABEL = "" # set per subclass: 'tier1'|'tier2'|'tier3'
async def process(self, order_data: dict) -> list[str]:
order_number = order_data["name"]
entity = order_data.get("entity", {}) or {}
entity_id = entity.get("id")
if not entity_id:
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: no telecom_entity_id on order. "
"Link the entity + re-dispatch, or bump the storage plan "
"manually in cdr_ingestion_profiles.storage_plan.",
)
return []
try:
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
cur.execute(
"""
UPDATE cdr_ingestion_profiles
SET storage_plan = %s,
storage_plan_purchased_at = NOW(),
storage_plan_order_ref = %s
WHERE telecom_entity_id = %s
RETURNING id
""",
(self.TIER_LABEL, order_number, entity_id),
)
row = cur.fetchone()
conn.commit()
conn.close()
except psycopg2.errors.UndefinedColumn:
# Columns don't exist yet — fall back to setting just storage_plan
try:
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
cur.execute(
"UPDATE cdr_ingestion_profiles SET storage_plan = %s "
"WHERE telecom_entity_id = %s RETURNING id",
(self.TIER_LABEL, entity_id),
)
row = cur.fetchone()
conn.commit()
conn.close()
except Exception as exc:
logger.warning(
"%s: storage_plan column missing + fallback failed: %s",
self.SERVICE_SLUG, exc,
)
row = None
except Exception as exc:
logger.exception("%s: DB error: %s", self.SERVICE_SLUG, exc)
row = None
if not row:
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME} for entity {entity_id}: no CDR profile "
"found. Customer needs to enable CDR ingestion first, OR "
"admin should provision the profile manually.",
)
return []
logger.info(
"%s: set storage_plan=%s on profile for entity %s (order %s)",
self.SERVICE_SLUG, self.TIER_LABEL, entity_id, order_number,
)
return [] # no artifacts — tier updates are quiet
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}",
"priority": "Low",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)
class CDRStorageTier1Handler(CDRStorageTierHandler):
SERVICE_SLUG = "cdr-storage-tier1"
SERVICE_NAME = "CDR Storage Tier 1 (50 GB / 50M calls)"
TIER_LABEL = "tier1"
class CDRStorageTier2Handler(CDRStorageTierHandler):
SERVICE_SLUG = "cdr-storage-tier2"
SERVICE_NAME = "CDR Storage Tier 2 (250 GB / 250M calls)"
TIER_LABEL = "tier2"
class CDRStorageTier3Handler(CDRStorageTierHandler):
SERVICE_SLUG = "cdr-storage-tier3"
SERVICE_NAME = "CDR Storage Tier 3 (1 TB / 1B calls)"
TIER_LABEL = "tier3"

View file

@ -0,0 +1,119 @@
"""Consent Management Audit handler (LLM-based).
Audits an organization's consent collection, storage, and management practices
across digital and offline channels against CCPA, GDPR, TCPA, CAN-SPAM,
and state-specific consent requirements.
"""
from __future__ import annotations
import os
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating a Consent Management Compliance Audit report.
RULES:
- Write in professional, clear business English
- Reference applicable consent frameworks: CCPA/CPRA opt-out, GDPR Article 7,
TCPA express written consent, CAN-SPAM, state biometric consent laws, etc.
- Never provide legal advice use "we recommend" not "you must"
- For each finding: what was found, regulation, risk level (Low/Medium/High/Critical), remediation
- Evaluate consent across all channels: web, mobile, email, phone, in-person
- Assess consent records for completeness and auditability
- Consider cookie consent, marketing consent, data processing consent, and sensitive data consent
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the consent management audit. "
"Include scope of review, overall consent compliance posture, and "
"highest-risk findings."
),
},
{
"name": "consent_framework_analysis",
"prompt": (
"Map out the applicable consent requirements for this organization. "
"Based on their industry, locations, and data practices, identify "
"which consent frameworks apply (opt-in vs. opt-out, affirmative vs. "
"implied, prior express written consent) and any conflicts between "
"overlapping regulations."
),
},
{
"name": "collection_practices",
"prompt": (
"Audit consent collection practices across all channels. For each "
"channel (website forms, cookie banners, mobile app, email signup, "
"phone, in-person): how is consent obtained, what disclosures are "
"provided, is consent granular or bundled, pre-checked boxes, dark "
"patterns assessment."
),
},
{
"name": "consent_records",
"prompt": (
"Evaluate the organization's consent record management. Assess: "
"what is recorded (timestamp, IP, version, scope), storage method, "
"retrieval capability, ability to demonstrate consent was given, "
"consent version tracking, and audit trail completeness."
),
},
{
"name": "withdrawal_and_revocation",
"prompt": (
"Review consent withdrawal and revocation processes. Assess: ease "
"of withdrawal (must be as easy as giving consent), processing "
"timelines, downstream propagation to processors and partners, "
"preference center functionality, and universal opt-out mechanisms."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized remediation plan for consent compliance gaps. "
"For each finding: reference, risk level, recommended action, "
"responsible party, timeline. Address quick wins (banner fixes) "
"and longer-term improvements (consent management platform)."
),
},
]
class ConsentAuditHandler(BaseServiceHandler):
SERVICE_SLUG = "consent-audit"
SERVICE_NAME = "Consent Management Audit"
TEMPLATE_NAME = "consent_audit_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
self._add_sections_to_doc(docx_path, sections)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,105 @@
"""Contractor Classification Review handler (LLM-based).
Analyzes independent contractor vs. employee classification under IRS, DOL,
and applicable state tests (ABC test, economic reality test, etc.).
"""
from __future__ import annotations
import os
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating an Independent Contractor Classification Review report.
RULES:
- Write in professional, clear business English
- Reference the IRS 20-factor test, DOL economic reality test, and relevant state ABC tests
- Never provide legal advice use "we recommend" not "you must"
- For each worker reviewed: classification determination, supporting factors, risk assessment
- Note state-specific tests (CA AB5, MA ABC test, NJ ABC test, etc.)
- Structure findings with clear headings and risk ratings (Low/Medium/High/Critical)
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the contractor classification "
"review. Include scope, number of contractor relationships reviewed, "
"overall risk posture, and key recommendations."
),
},
{
"name": "legal_framework",
"prompt": (
"Summarize the applicable legal framework for worker classification. "
"Cover: IRS common-law test (behavioral control, financial control, "
"relationship type), DOL economic reality test, state-specific tests "
"applicable to this client, and recent enforcement trends."
),
},
{
"name": "classification_analysis",
"prompt": (
"Analyze each contractor relationship against the applicable tests. "
"For each: describe the engagement, apply the IRS factors, apply the "
"DOL economic reality test, apply any relevant state test, and give "
"a risk rating. Flag any relationships that may warrant reclassification."
),
},
{
"name": "agreement_review",
"prompt": (
"Review the contractor agreements for protective language. Assess: "
"scope of work specificity, control provisions, termination clauses, "
"IP ownership, non-compete/non-solicitation, indemnification, and "
"whether the agreements align with actual practice."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized remediation plan. For each finding: reference, "
"risk level, recommended action, responsible party, timeline. Include "
"recommendations for reclassification processes, back-tax exposure "
"mitigation (e.g., Section 530 relief, VCSP), and agreement revisions."
),
},
]
class ContractorReviewHandler(BaseServiceHandler):
SERVICE_SLUG = "contractor-classification"
SERVICE_NAME = "Contractor Classification Review"
TEMPLATE_NAME = "contractor_review_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
self._add_sections_to_doc(docx_path, sections)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,330 @@
"""CORES / FRN Registration handler.
Entry point for every new carrier. Without an FRN (10-digit FCC Registration
Number) the carrier cannot file anything else on the FCC side. This
handler creates a CORES business account + FRN on the carrier's behalf,
stores the username + a secret-hashed password for identity-verification
on support calls, and delivers a credential packet PDF.
Flow:
1. Intake-driven Playwright session against
https://apps.fcc.gov/coresWeb/publicHome.do
2. Create business account (email, name, phone, address)
3. Register FRN capture assigned 10-digit number
4. Configure password-recovery email to the customer's address
5. Persist FRN + username + bcrypt(password) on telecom_entities
6. Generate credential packet PDF, upload to MinIO, deliver to customer
Auto-filing toggle is honored admin review before submission when off.
"""
from __future__ import annotations
import hashlib
import logging
import os
import secrets
from datetime import datetime
from typing import Optional
from .base_handler import BaseServiceHandler
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay, type_slowly
logger = logging.getLogger(__name__)
CORES_URL = os.environ.get(
"FCC_CORES_URL", "https://apps.fcc.gov/coresWeb/publicHome.do",
)
class CORESFRNRegistrationHandler(BaseServiceHandler):
SERVICE_SLUG = "cores-frn-registration"
SERVICE_NAME = "CORES / FRN Registration"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
intake = order_data.get("intake_data") or {}
entity_id = entity.get("id")
generated: list[str] = []
# Required intake fields
legal_name = intake.get("legal_name") or entity.get("legal_name", "")
ein = intake.get("ein") or entity.get("ein", "")
officer = intake.get("officer") or {}
address = intake.get("address") or {
"street": entity.get("address_street", ""),
"city": entity.get("address_city", ""),
"state": entity.get("address_state", ""),
"zip": entity.get("address_zip", ""),
}
recovery_email = intake.get("password_recovery_email") or officer.get("email")
if not (legal_name and officer.get("name") and officer.get("email") and recovery_email):
self._create_admin_todo(
order_number,
"CORES/FRN registration requires legal_name, officer.name, "
"officer.email, and password_recovery_email in intake_data. "
"Re-dispatch once the customer completes intake.",
)
return generated
# If this entity already has an FRN, skip.
if entity.get("frn"):
logger.info(
"CORESFRNRegistrationHandler: entity %s already has FRN %s"
"producing credential packet only",
entity_id, entity.get("frn"),
)
packet = self._write_credential_packet(
order_number, entity, officer, recovery_email,
work_dir=work_dir,
)
if packet:
generated.append(packet)
return generated
# Auto-filing gate
decision = check_auto_filing(order_data)
if not decision.may_submit:
logger.info(
"CORESFRNRegistrationHandler: %s — staging for admin review",
decision.reason,
)
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=legal_name,
frn="",
packet_minio_paths=[],
admin_email=decision.admin_email,
summary=(
f"New CORES account + FRN registration for {legal_name}. "
f"Intake: officer={officer.get('name')}, "
f"recovery_email={recovery_email}, "
f"address={address.get('street')}, {address.get('city')}, "
f"{address.get('state')} {address.get('zip')}"
),
)
return generated
# Generate a random password we'll use for the account; the customer
# rotates it from the credential packet. Plaintext is packet-only.
password = secrets.token_urlsafe(20)
frn, username = await self._register_in_cores(
legal_name=legal_name,
ein=ein,
officer=officer,
address=address,
password=password,
recovery_email=recovery_email,
order_number=order_number,
work_dir=work_dir,
)
if frn and entity_id:
self._persist_frn(entity_id, frn, username, password)
elif not frn:
return generated # Admin ToDo already created by the sub-function
packet = self._write_credential_packet(
order_number, entity, officer, recovery_email,
username=username, password=password, frn=frn, work_dir=work_dir,
)
if packet:
generated.append(packet)
try:
generated.append(self._convert_to_pdf(packet))
except Exception as exc:
logger.warning("Credential packet PDF conversion failed: %s", exc)
return generated
# ------------------------------------------------------------------ #
# CORES Playwright flow
# ------------------------------------------------------------------ #
async def _register_in_cores(
self, *, legal_name: str, ein: str, officer: dict, address: dict,
password: str, recovery_email: str, order_number: str, work_dir: str,
) -> tuple[str, str]:
"""Returns (frn, username). Empty strings on failure + admin ToDo."""
username = (recovery_email or officer.get("email") or "").strip().lower()
try:
async with undetected_browser(headless=True) as (ctx, page):
await page.goto(CORES_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
# Step 1 — create CORES business account
await page.click("text=Register a New FRN")
await human_delay()
await type_slowly(page, 'input[name="businessName"]', legal_name)
await type_slowly(page, 'input[name="contactEmail"]', username)
await type_slowly(page, 'input[name="contactName"]', officer.get("name", ""))
await type_slowly(page, 'input[name="contactPhone"]', officer.get("phone", ""))
await page.fill('input[name="password"]', password)
await page.fill('input[name="passwordConfirm"]', password)
if ein:
await page.fill('input[name="taxpayerId"]', ein.replace("-", ""))
# Address block
await page.fill('input[name="street1"]', address.get("street", ""))
await page.fill('input[name="city"]', address.get("city", ""))
await page.fill('input[name="state"]', address.get("state", ""))
await page.fill('input[name="zip"]', address.get("zip", ""))
# Recovery email (same as contact by default)
await page.fill('input[name="recoveryEmail"]', recovery_email)
# Agree + submit
await page.check('input[name="tos"]')
await page.click('button[type="submit"]')
await page.wait_for_selector("text=FRN", timeout=60000)
await human_delay(2.0, 4.0)
# Extract FRN from the confirmation page
body = await page.locator("body").inner_text()
frn = ""
for line in body.splitlines():
if "FRN" in line:
# Looking for the 10-digit FRN number
import re
m = re.search(r"\b(\d{10})\b", line)
if m:
frn = m.group(1)
break
# Save confirmation snapshot
conf_path = os.path.join(
work_dir, f"cores_confirmation_{order_number}.pdf",
)
await page.pdf(path=conf_path, format="Letter")
if not frn:
self._create_admin_todo(
order_number,
"CORES registration completed but FRN number could not be "
"extracted from the confirmation page. Check MinIO for the "
"confirmation PDF; pull the FRN manually and update the "
"telecom_entity.",
)
return "", username
logger.info(
"CORESFRNRegistrationHandler: assigned FRN %s for %s",
frn, legal_name,
)
return frn, username
except Exception as exc:
logger.exception(
"CORESFRNRegistrationHandler: Playwright flow failed: %s", exc,
)
self._create_admin_todo(
order_number,
f"CORES registration Playwright flow raised: {exc}. "
f"Perform CORES account + FRN creation manually at "
f"{CORES_URL}; update the entity with the resulting FRN.",
)
return "", username
# ------------------------------------------------------------------ #
# Persistence
# ------------------------------------------------------------------ #
def _persist_frn(self, entity_id: int, frn: str, username: str,
password: str) -> None:
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
cur.execute(
"""
UPDATE telecom_entities SET
frn = %s,
cores_username = %s,
cores_password_hash = %s,
cores_registered_at = NOW()
WHERE id = %s
""",
(
frn, username,
hashlib.sha256(password.encode("utf-8")).hexdigest(),
entity_id,
),
)
conn.commit()
conn.close()
except Exception as exc:
logger.warning("Could not persist FRN on entity %s: %s", entity_id, exc)
# ------------------------------------------------------------------ #
# Credential packet DOCX
# ------------------------------------------------------------------ #
def _write_credential_packet(
self, order_number: str, entity: dict, officer: dict,
recovery_email: str, *, work_dir: str,
username: str = "", password: str = "", frn: str = "",
) -> str:
from docx import Document
doc = Document()
doc.add_heading(f"CORES / FRN Credentials — {entity.get('legal_name', '')}", level=1)
doc.add_paragraph(f"Date: {datetime.now().strftime('%B %d, %Y')}")
doc.add_paragraph(f"Order: {order_number}")
doc.add_paragraph("")
doc.add_paragraph(
"Performance West Inc. has completed your CORES business account "
"registration and obtained your FCC Registration Number (FRN). "
"Keep this document in a safe place — we do not retain your "
"password in plaintext."
)
doc.add_heading("Your FRN", level=2)
doc.add_paragraph(frn or entity.get("frn", "(pending)")).bold = True
doc.add_heading("CORES Login", level=2)
doc.add_paragraph(f"URL: https://apps.fcc.gov/coresWeb/publicHome.do")
doc.add_paragraph(f"Username: {username or officer.get('email', '')}")
if password:
doc.add_paragraph(f"Password: {password}")
doc.add_paragraph(
"We recommend changing this password immediately after "
"your first login. The password-recovery email on file is "
f"{recovery_email}."
)
else:
doc.add_paragraph(
f"Password: (unchanged from your prior setup — use the "
f"recovery email {recovery_email} if you need to reset it)"
)
doc.add_heading("Recovery + Support", level=2)
doc.add_paragraph(
"If you lose access to CORES, use the recovery email to request "
"a password reset. Performance West can verify your identity via "
"the password hash on file — call us with the order number above."
)
out = os.path.join(work_dir, f"cores_credentials_{order_number}.docx")
doc.save(out)
return out
# ------------------------------------------------------------------ #
# Admin ToDo fallback
# ------------------------------------------------------------------ #
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}",
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

View file

@ -0,0 +1,404 @@
"""CPNI Annual Certification filing handler.
Submits the Annual 47 C.F.R. § 64.2009(e) CPNI Certification to the FCC's
Electronic Comment Filing System (ECFS) under WC Docket No. 06-36. The
filing window opens each year in January and closes March 1.
Flow:
1. Generate the CPNI certification letter via the existing generator.
2. Convert to PDF.
3. Launch undetected browser, navigate to ECFS express comment form,
upload the signed letter, and submit under docket 06-36.
4. Capture confirmation page (ECFS assigns a document number like
``2026XXXXXXXX``) and persist it.
Idempotency: if the carrier already has a CPNI cert on record for the
current calendar year, skip the submission and deliver docs only.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
from .telecom import filing_state
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay, type_slowly
logger = logging.getLogger(__name__)
ECFS_URL = os.environ.get(
"FCC_ECFS_URL",
"https://www.fcc.gov/ecfs/search/proceedings?name=06-36",
)
ECFS_UPLOAD_URL = os.environ.get(
"FCC_ECFS_UPLOAD_URL",
"https://www.fcc.gov/ecfs/upload/express",
)
CPNI_DOCKET = "06-36"
class CPNIFilingHandler(BaseServiceHandler):
SERVICE_SLUG = "cpni-certification"
SERVICE_NAME = "CPNI Annual Certification"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
date_str = datetime.now().strftime("%Y%m%d")
# ── Guard: require entity data before generating documents ───────
legal_name = entity.get("legal_name", "").strip()
if not legal_name:
logger.warning(
"CPNIFilingHandler: no entity data for %s — pausing for intake",
order_number,
)
self._request_entity_intake(order_data)
return []
# CPNI questionnaire answers from intake_data (filled by CPNIStep)
intake = order_data.get("intake_data", {}) or {}
cpni_answers = intake.get("cpni", {})
generated: list[str] = []
# ── 1. Generate packet ───────────────────────────────────────────
cpni_docx = os.path.join(
work_dir, f"cpni_certification_{order_number}_{date_str}.docx"
)
# Pick the Line 105 primary-category-specific variant if available.
variant_fn = self._pick_template_generator(entity)
if variant_fn is not None:
logger.info(
"CPNIFilingHandler: using variant generator %s for entity %s",
getattr(variant_fn, "__name__", "<unknown>"),
entity.get("id"),
)
result = variant_fn(
output_path=cpni_docx,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
filer_id_499=entity.get("filer_id_499", ""),
officer_name=entity.get("ceo_name") or entity.get("contact_name", ""),
officer_title=entity.get("ceo_title", "Chief Executive Officer"),
complaints_count=entity.get("cpni_complaints_count", 0),
has_data_broker_inquiries=bool(
entity.get("has_data_broker_inquiries", False)
),
reporting_year=entity.get("reporting_year", 0),
address_street=entity.get("address_street", ""),
address_city=entity.get("address_city", ""),
address_state=entity.get("address_state", ""),
address_zip=entity.get("address_zip", ""),
contact_email=entity.get("contact_email", ""),
contact_phone=entity.get("contact_phone", ""),
)
else:
from scripts.document_gen.templates.cpni_cert_letter_generator import (
generate_cpni_cert_letter,
)
result = generate_cpni_cert_letter(
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
filer_id_499=entity.get("filer_id_499", ""),
address_street=entity.get("address_street", ""),
address_city=entity.get("address_city", ""),
address_state=entity.get("address_state", ""),
address_zip=entity.get("address_zip", ""),
officer_name=entity.get("ceo_name") or entity.get("contact_name", ""),
officer_title=entity.get("ceo_title", "Chief Executive Officer"),
contact_email=entity.get("contact_email", ""),
contact_phone=entity.get("contact_phone", ""),
complaints_count=int(cpni_answers.get("complaints_count", 0) or 0),
complaints_description=cpni_answers.get("complaints_description", ""),
# Breaches — build the list[dict] format the generator expects
breaches=(
[{
"date": "during the reporting period",
"description": cpni_answers.get("breaches_description", ""),
"records_affected": 0,
"notified_fcc": cpni_answers.get("breaches_notified", "yes") in ("yes", "partial"),
"notified_customers": cpni_answers.get("breaches_notified", "yes") == "yes",
}] if cpni_answers.get("breaches", "no") != "no" else []
),
disciplinary_actions_taken=cpni_answers.get("disciplinary", "no") != "no",
disciplinary_actions_description=cpni_answers.get("disciplinary_description", ""),
data_broker_actions=cpni_answers.get("data_brokers_description", ""),
uses_cpni_for_marketing=cpni_answers.get("marketing_usage", "no") != "no",
cpni_approval_method=cpni_answers.get("marketing_usage", "no") if cpni_answers.get("marketing_usage", "no") != "no" else "opt_in",
reporting_year=int(cpni_answers.get("reporting_year", 0) or 0),
is_wholesale=entity.get("is_wholesale", False),
output_path=cpni_docx,
)
packet_pdf: str | None = None
if result:
generated.append(result)
try:
packet_pdf = self._convert_to_pdf(result)
generated.append(packet_pdf)
except Exception as exc:
logger.warning("CPNI letter PDF conversion failed: %s", exc)
# ── 1b. CPNI Procedure Statement (47 CFR § 64.2008 annual notice) ─
# Separate deliverable from the ECFS cert letter: the internal
# policy document every example filing includes alongside the
# cert. Customers post this on their website and send it to
# customers annually.
try:
from scripts.document_gen.templates.cpni_procedure_statement_generator import (
generate_cpni_procedure_statement,
)
policy_docx = os.path.join(
work_dir, f"cpni_procedure_statement_{order_number}_{date_str}.docx"
)
policy_path = generate_cpni_procedure_statement(
entity_name=entity.get("legal_name", ""),
support_email=entity.get("contact_email", ""),
website=entity.get("website", ""),
signatory_name=entity.get("ceo_name") or entity.get("contact_name", ""),
signatory_title=entity.get("ceo_title", "Chief Executive Officer"),
is_wholesale=entity.get("is_wholesale", False),
output_path=policy_docx,
)
if policy_path:
generated.append(policy_path)
try:
generated.append(self._convert_to_pdf(policy_path))
except Exception as exc:
logger.warning(
"CPNI procedure statement PDF conversion failed: %s",
exc,
)
except Exception as exc:
logger.warning("CPNI procedure statement generation failed: %s", exc)
# ── 2. Idempotency ──────────────────────────────────────────────
if entity_id and filing_state.already_filed(entity_id, "cpni"):
logger.info(
"CPNIFilingHandler: already on file for entity %s this cycle", entity_id
)
return generated
# ── 2a. Auto-filing toggle ──────────────────────────────────────
decision = check_auto_filing(order_data)
if not decision.may_submit:
logger.info(
"CPNIFilingHandler: %s — staging for admin review (order=%s)",
decision.reason, order_number,
)
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in generated],
admin_email=decision.admin_email,
summary=(
f"CPNI annual certification packet ready for ECFS docket "
f"{CPNI_DOCKET}. 2026 deadline is March 2."
),
)
return generated
# ── 3. Submit to ECFS ───────────────────────────────────────────
confirmation_path, confirmation_number = await self._submit_to_ecfs(
order_number=order_number,
entity=entity,
packet_pdf=packet_pdf or cpni_docx,
work_dir=work_dir,
)
if confirmation_path:
generated.append(confirmation_path)
# ── 4. Persist success + email confirmation to client ──────────
if entity_id and confirmation_number:
filing_state.record_cpni_filing(entity_id, confirmation_number)
if confirmation_number:
try:
from scripts.workers.job_server import _send_filing_confirmation
from scripts.document_gen import MinioStorage
customer_email = order_data.get("customer_email") or entity.get("contact_email")
customer_name = order_data.get("customer_name") or entity.get("contact_name", "")
if customer_email:
conf_paths = [p for p in generated if "confirmation" in p.lower()]
_send_filing_confirmation(
customer_email=customer_email,
customer_name=customer_name,
order_number=order_number,
service_name=self.SERVICE_NAME,
confirmation_number=confirmation_number,
authority="FCC Electronic Comment Filing System (ECFS)",
minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in conf_paths],
storage=MinioStorage(),
)
except Exception as exc:
logger.warning("CPNI filing confirmation email failed: %s", exc)
return generated
# ------------------------------------------------------------------ #
# Variant selection
# ------------------------------------------------------------------ #
def _pick_template_generator(self, entity: dict):
"""
Resolve the right CPNI generator based on the entity's Line 105
primary category and that category's infra_type.
Returns a callable ``generate_cpni_<variant>(...)`` on success, or
``None`` to indicate the caller should fall back to the generic
``cpni_cert_letter_generator.generate_cpni_cert_letter``.
"""
primary = entity.get("line_105_primary") or entity.get(
"filer_type", "voip_interconnected"
)
cats = entity.get("line_105_categories") or []
entry = next((c for c in cats if c.get("id") == primary), {})
infra = entry.get("infra_type", "facilities")
mapping = {
("clec", "facilities"): "cpni_clec_generator",
("clec", "reseller"): "cpni_clec_reseller_generator",
("ixc", "facilities"): "cpni_ixc_generator",
("ixc", "reseller"): "cpni_ixc_reseller_generator",
("wireless", "facilities"): "cpni_wireless_generator",
("wireless", "mvno"): "cpni_wireless_mvno_generator",
("satellite", "facilities"): "cpni_satellite_generator",
("audio_bridging", "facilities"): "cpni_audio_bridge_generator",
("private_line", "facilities"): "cpni_private_line_generator",
}
module_name = mapping.get((primary, infra))
if not module_name:
return None
try:
import importlib
mod = importlib.import_module(
f"scripts.document_gen.templates.{module_name}"
)
fn_name = "generate_" + module_name.replace("_generator", "")
return getattr(mod, fn_name)
except Exception as exc:
logger.warning(
"CPNIFilingHandler: failed to load CPNI variant %s: %s",
module_name, exc,
)
return None
# ------------------------------------------------------------------ #
# ECFS Express Upload flow
# ------------------------------------------------------------------ #
async def _submit_to_ecfs(
self,
*,
order_number: str,
entity: dict,
packet_pdf: str,
work_dir: str,
) -> tuple[str | None, str]:
confirmation_path = os.path.join(
work_dir, f"cpni_confirmation_{order_number}.pdf"
)
confirmation_number = ""
try:
async with undetected_browser(headless=True) as (ctx, page):
await page.goto(ECFS_UPLOAD_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
# Express comment form — fields are: Proceedings, Name of
# filer, Filer's address, upload, type of filing.
await type_slowly(page, 'input[name="proceedings"]', CPNI_DOCKET)
# Dropdown suggestion click
await page.wait_for_selector(
f'li:has-text("{CPNI_DOCKET}")', timeout=10000
)
await page.click(f'li:has-text("{CPNI_DOCKET}")')
await human_delay()
await type_slowly(
page, 'input[name="name_of_filer"]', entity.get("legal_name", "")
)
await type_slowly(
page, 'input[name="filer_email"]', entity.get("contact_email", "")
)
# Address
await page.fill(
'input[name="address_line_1"]', entity.get("address_street", "")
)
await page.fill('input[name="city"]', entity.get("address_city", ""))
await page.fill('input[name="state"]', entity.get("address_state", ""))
await page.fill('input[name="zip_code"]', entity.get("address_zip", ""))
# Type of filing — "Certification"
await page.select_option(
'select[name="type_of_filing"]', label="Certification"
)
# Upload the signed PDF.
await page.set_input_files('input[type="file"]', packet_pdf)
await human_delay(1.0, 2.0)
await page.click('button:has-text("Continue")')
await page.wait_for_selector("text=Review", timeout=30000)
await human_delay(2.0, 4.0)
await page.click('button:has-text("Submit")')
await page.wait_for_selector("text=Confirmation", timeout=60000)
body = await page.locator("body").inner_text()
for line in body.splitlines():
if "Filing ID" in line or "Confirmation" in line:
parts = line.split(":", 1)
if len(parts) == 2 and parts[1].strip():
confirmation_number = parts[1].strip()
break
await page.pdf(path=confirmation_path, format="Letter")
logger.info(
"CPNIFilingHandler: submitted under docket %s, filing ID %s",
CPNI_DOCKET,
confirmation_number,
)
return confirmation_path, confirmation_number
except Exception as exc:
logger.exception("CPNIFilingHandler: ECFS submission failed: %s", exc)
self._create_admin_todo(
order_number,
f"ECFS CPNI certification submission failed: {exc}. "
f"Packet is available in MinIO under compliance/{order_number}/. "
"File manually at https://www.fcc.gov/ecfs/ under docket 06-36.",
)
return None, ""
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

View file

@ -0,0 +1,210 @@
"""D.C. Registered Agent service handler.
Most telecom carriers need a D.C. registered agent because the FCC
requires process-of-service in the District. We wholesale this through
Northwest Registered Agent (same pattern as state-level RA services per
``docs/go-live-todo.md:96``). Northwest's RA portal is login-only and
requires manual order entry; rather than scrape it, this handler creates
a well-formed admin ToDo with all the fields the Accounting Advisor
needs to place the wholesale order in under two minutes.
Once the Accounting Advisor completes the Northwest order, they record
the D.C. agent address on the telecom_entity's ``carrier_metadata``
field under ``dc_agent_address`` so subsequent compliance checkups read
green.
Flow:
1. Generate a simple acceptance-of-service PDF letter (for the customer's
records) acknowledging that Performance West has engaged Northwest
as their D.C. RA.
2. Create the admin ToDo with the wholesale order fields filled in.
3. Return the acceptance letter.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
# Northwest Registered Agent's D.C. Registered Agent address — used on
# FCC Form 499-A Lines 209-213. This is the wholesale address we place
# orders against; confirmed via the 2025 Adaptive Communications 499-A
# filing in docs/examplefilings/. Stable across all carriers we've filed.
NWRA_DC_AGENT = {
"company": "Northwest Registered Agent Service Inc.",
"street": "1717 N Street NW STE 1",
"city": "Washington",
"state": "DC",
"zip": "20036",
"phone": "509-768-2249",
"email": "support@northwestregisteredagent.com",
}
class DCAgentHandler(BaseServiceHandler):
SERVICE_SLUG = "dc-agent"
SERVICE_NAME = "D.C. Registered Agent (Annual)"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
generated: list[str] = []
acceptance = self._write_acceptance_letter(order_number, entity, work_dir)
if acceptance:
generated.append(acceptance)
try:
generated.append(self._convert_to_pdf(acceptance))
except Exception as exc:
logger.warning("DC agent acceptance PDF conversion failed: %s", exc)
# Persist the NWRA D.C. Agent address on the telecom_entity so the
# Form 499-A checklist generator can read it for Lines 209-213
# without re-computing. Columns default to NWRA values via
# migration 048, so a new order does not need this; but an older
# entity created before migration 048 will still get them written.
if entity_id:
self._persist_dc_agent(entity_id)
self._create_wholesale_order_todo(order_number, entity)
return generated
# ------------------------------------------------------------------ #
# Persist NWRA D.C. agent address on the telecom_entity
# ------------------------------------------------------------------ #
def _persist_dc_agent(self, entity_id: int) -> None:
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
cur.execute(
"""
UPDATE telecom_entities SET
dc_agent_company = %s,
dc_agent_street = %s,
dc_agent_city = %s,
dc_agent_state = %s,
dc_agent_zip = %s,
dc_agent_phone = %s,
dc_agent_email = %s
WHERE id = %s
""",
(
NWRA_DC_AGENT["company"],
NWRA_DC_AGENT["street"],
NWRA_DC_AGENT["city"],
NWRA_DC_AGENT["state"],
NWRA_DC_AGENT["zip"],
NWRA_DC_AGENT["phone"],
NWRA_DC_AGENT["email"],
entity_id,
),
)
conn.commit()
conn.close()
logger.info(
"DCAgentHandler: persisted NWRA D.C. agent on entity %s",
entity_id,
)
except Exception as exc:
logger.warning(
"DCAgentHandler: could not persist D.C. agent on entity %s: %s",
entity_id, exc,
)
# ------------------------------------------------------------------ #
# Acceptance letter (plain DOCX — no template)
# ------------------------------------------------------------------ #
def _write_acceptance_letter(
self, order_number: str, entity: dict, work_dir: str
) -> str:
from docx import Document
date_str = datetime.now().strftime("%B %d, %Y")
entity_name = entity.get("legal_name", "")
dba = entity.get("dba_name", "")
display = f"{entity_name} ({dba})" if dba else entity_name
doc = Document()
doc.add_heading("D.C. Registered Agent — Acceptance of Engagement", level=1)
doc.add_paragraph(f"Date: {date_str}")
doc.add_paragraph(f"Order: {order_number}")
doc.add_paragraph(f"Client: {display}")
doc.add_paragraph(f"FRN: {entity.get('frn', 'N/A')}")
doc.add_paragraph("")
doc.add_paragraph(
"This letter confirms that Performance West Inc. has engaged "
"Northwest Registered Agent Service Inc. as D.C. Registered "
"Agent on behalf of the client identified above. Northwest "
"maintains a registered office in the District of Columbia "
"and will receive service of process on the client's behalf "
"during the engagement period (one year from the order date)."
)
doc.add_paragraph(
"D.C. registered agent address (use on FCC Form 499-A "
"Lines 209\u2013213):"
)
doc.add_paragraph(
f" {NWRA_DC_AGENT['company']}\n"
f" {NWRA_DC_AGENT['street']}\n"
f" {NWRA_DC_AGENT['city']}, {NWRA_DC_AGENT['state']} {NWRA_DC_AGENT['zip']}\n"
f" Tel: {NWRA_DC_AGENT['phone']}\n"
f" Email: {NWRA_DC_AGENT['email']}"
)
doc.add_paragraph("")
doc.add_paragraph("Performance West Inc.")
doc.add_paragraph("Regulatory Compliance Team")
out = os.path.join(
work_dir, f"dc_agent_acceptance_{order_number}.docx"
)
doc.save(out)
return out
# ------------------------------------------------------------------ #
# Admin ToDo for wholesale order placement
# ------------------------------------------------------------------ #
def _create_wholesale_order_todo(self, order_number: str, entity: dict) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
description = (
f"[{self.SERVICE_SLUG}] {order_number}\n\n"
f"Place a D.C. Registered Agent wholesale order with Northwest "
f"Registered Agent for this client. Once complete, record the "
f"assigned D.C. agent address on the telecom entity's "
f"carrier_metadata.dc_agent_address and send the customer a "
f"follow-up email with the address.\n\n"
f"Wholesale order fields:\n"
f" Entity legal name: {entity.get('legal_name', '')}\n"
f" DBA: {entity.get('dba_name', '')}\n"
f" FRN: {entity.get('frn', 'N/A')}\n"
f" Contact name: {entity.get('contact_name', '')}\n"
f" Contact email: {entity.get('contact_email', '')}\n"
f" Contact phone: {entity.get('contact_phone', '')}\n"
f" Carrier category: {entity.get('carrier_category', '')}\n\n"
f"Billing: use the Relay virtual debit card (SID-0002, filing fees)."
)
ERPNextClient().create_resource(
"ToDo",
{
"description": description,
"priority": "Medium",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create DC agent wholesale ToDo: %s", exc)

View file

@ -0,0 +1,123 @@
"""Do-Not-Call (DNC) Compliance Review handler (LLM-based).
Reviews an organization's telemarketing practices and Do-Not-Call compliance
under the Telephone Consumer Protection Act (TCPA), TSR, FCC rules, and
state DNC registries.
"""
from __future__ import annotations
import os
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating a Do-Not-Call (DNC) Compliance Review report.
RULES:
- Write in professional, clear business English
- Reference TCPA (47 U.S.C. § 227), FCC rules (47 CFR § 64.1200), and
FTC Telemarketing Sales Rule (16 CFR Part 310)
- Reference applicable state DNC laws and registries
- Never provide legal advice use "we recommend" not "you must"
- For each finding: what was found, regulation, risk level, remediation
- Consider: national DNC registry, internal DNC lists, EBR exemptions,
prior express consent, autodialer/prerecorded message rules, caller ID requirements
- Note TCPA statutory damages ($500-$1,500 per violation) as risk context
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the DNC compliance review. "
"Include scope, calling volume metrics if available, overall "
"compliance posture, and critical findings."
),
},
{
"name": "regulatory_landscape",
"prompt": (
"Summarize the applicable DNC regulatory framework for this "
"organization. Cover: TCPA, FCC rules, FTC TSR, state DNC "
"registries applicable to their calling patterns, and recent "
"enforcement actions and FCC declaratory rulings that affect "
"their operations."
),
},
{
"name": "dnc_list_management",
"prompt": (
"Audit DNC list management practices. Assess: national DNC "
"registry scrubbing frequency (must be every 31 days), state "
"DNC registry subscriptions, internal DNC list maintenance, "
"DNC request processing timeline (must honor within reasonable "
"time), entity-specific vs. seller-specific DNC, and EBR "
"exemption tracking."
),
},
{
"name": "consent_and_calling_practices",
"prompt": (
"Review consent management for telemarketing. Assess: prior "
"express written consent for autodialed/prerecorded calls, "
"consent revocation processes, consent record retention, "
"calling time restrictions (8am-9pm), caller ID transmission, "
"and abandoned call rates (max 3% per campaign per 30 days)."
),
},
{
"name": "technology_compliance",
"prompt": (
"Evaluate technology and systems compliance. Assess: autodialer "
"definition under current TCPA interpretation, predictive dialer "
"settings, prerecorded message opt-out mechanisms (press 2 to "
"opt-out), interactive voice response compliance, and call "
"recording consent (two-party consent states)."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized remediation plan. For each finding: "
"reference, risk level (note TCPA statutory damages exposure), "
"recommended action, responsible party, and timeline. Address "
"both immediate risk reduction and long-term program improvements."
),
},
]
class DNCReviewHandler(BaseServiceHandler):
SERVICE_SLUG = "dnc-compliance"
SERVICE_NAME = "Do-Not-Call Compliance Review"
TEMPLATE_NAME = "dnc_review_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
self._add_sections_to_doc(docx_path, sections)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,586 @@
"""FCC Carrier Compliance Checkup handler.
Orchestrates the full compliance checkup for a telecom entity:
1. Runs compliance checks (CORES, RMD, STIR/SHAKEN, CPNI, 499-A/Q)
2. Generates the RMD certification letter (carrier-type-specific)
3. Generates Exhibit A if needed (partial STIR/SHAKEN)
4. Generates CPNI certification letter (if due/overdue)
5. Generates 499-A filing prep checklist
6. Fills the compliance status report template
7. Converts all documents to PDF
8. Computes ``recommended_slugs`` the list of remediation services we
should upsell the customer into based on what the checks flagged.
These slugs feed the delivery-email deep-links and the bundle URL.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from pathlib import Path
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
# Map checkup findings → remediation service slugs. The slugs match the
# ERPNext Item codes / COMPLIANCE_SERVICES entries in
# ``api/src/routes/compliance-orders.ts``.
#
# Order matters: this is also the natural display order in the upsell
# email. Full-compliance bundle is handled separately downstream (the
# recommendations endpoint swaps the individual slugs for the bundle
# when 3+ remediations would fire).
_CHECK_TO_SLUG = {
"rmd_filing": "rmd-filing",
"stir_shaken": "stir-shaken",
"cpni": "cpni-certification",
"form_499a": "fcc-499a",
}
class FCCComplianceCheckupHandler(BaseServiceHandler):
SERVICE_SLUG = "fcc-compliance-checkup"
SERVICE_NAME = "FCC Carrier Compliance Checkup"
TEMPLATE_NAME = "fcc_compliance_report_template.docx"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
# Pull entity fields (passed in from job_server or looked up beforehand)
entity_name = entity.get("legal_name", order_data.get("customer_name", ""))
frn = entity.get("frn", "")
filer_id = entity.get("filer_id_499", "")
dba_name = entity.get("dba_name", "")
# Contact
contact_name = entity.get("contact_name", "")
contact_title = entity.get("contact_title", "")
contact_email = entity.get("contact_email", "")
contact_phone = entity.get("contact_phone", "")
ceo_name = entity.get("ceo_name", "")
ceo_title = entity.get("ceo_title", "Chief Executive Officer")
# Address
address_street = entity.get("address_street", "")
address_city = entity.get("address_city", "")
address_state = entity.get("address_state", "")
address_zip = entity.get("address_zip", "")
# Classification
carrier_category = entity.get("carrier_category", "interconnected_voip")
infra_type = entity.get("infra_type", "facilities")
is_wholesale = entity.get("is_wholesale", False)
is_gateway_provider = entity.get("is_gateway_provider", False)
is_international_only = entity.get("is_international_only", False)
uses_ucaas_provider = entity.get("uses_ucaas_provider", False)
carrier_metadata = entity.get("carrier_metadata", {})
stir_shaken_status = entity.get("stir_shaken_status", "complete_implementation")
stir_shaken_cert_authority = entity.get("stir_shaken_cert_authority", "")
upstream_provider_name = entity.get("upstream_provider_name", "")
upstream_provider_frn = entity.get("upstream_provider_frn", "")
# Revenue / classification
is_deminimis = entity.get("is_deminimis", False)
is_lire = entity.get("is_lire", False)
service_categories = entity.get("service_categories", [])
total_revenue_cents = entity.get("total_revenue_cents", 0)
interstate_pct = entity.get("interstate_pct", 0)
international_pct = entity.get("international_pct", 0)
last_filing_year = entity.get("last_filing_year", 0)
rmd_number = entity.get("rmd_number", "")
# Compliance check results (passed in from the API or job_server)
checks = order_data.get("compliance_checks", {})
generated_files: list[str] = []
date_str = datetime.now().strftime("%Y%m%d")
# ── 1. RMD Certification Letter ──────────────────────────
from scripts.document_gen.templates.rmd_letter_generator import (
generate_rmd_letter,
)
rmd_docx = os.path.join(
work_dir, f"rmd_certification_letter_{order_number}_{date_str}.docx"
)
result = generate_rmd_letter(
entity_name=entity_name,
dba_name=dba_name,
frn=frn,
rmd_number=rmd_number,
filer_id_499=filer_id,
address_street=address_street,
address_city=address_city,
address_state=address_state,
address_zip=address_zip,
contact_name=contact_name,
contact_title=contact_title,
contact_email=contact_email,
contact_phone=contact_phone,
ceo_name=ceo_name,
ceo_title=ceo_title,
carrier_category=carrier_category,
infra_type=infra_type,
is_wholesale=is_wholesale,
is_gateway_provider=is_gateway_provider,
is_international_only=is_international_only,
uses_ucaas_provider=uses_ucaas_provider,
carrier_metadata=carrier_metadata,
stir_shaken_status=stir_shaken_status,
stir_shaken_cert_authority=stir_shaken_cert_authority,
upstream_provider_name=upstream_provider_name,
upstream_provider_frn=upstream_provider_frn,
output_path=rmd_docx,
)
if result:
generated_files.append(result)
try:
generated_files.append(self._convert_to_pdf(result))
except Exception as exc:
logger.warning("RMD letter PDF conversion failed: %s", exc)
# ── 2. Exhibit A (if needed) ─────────────────────────────
needs_exhibit_a = stir_shaken_status in (
"partial_implementation",
"robocall_mitigation_only",
"exempt_small_carrier",
)
if needs_exhibit_a:
from scripts.document_gen.templates.rmd_letter_generator import (
_determine_primary_role,
)
from scripts.document_gen.templates.rmd_exhibit_a_generator import (
generate_exhibit_a,
)
carrier_role = _determine_primary_role(
is_gateway_provider=is_gateway_provider,
uses_ucaas_provider=uses_ucaas_provider,
is_wholesale=is_wholesale,
is_international_only=is_international_only,
infra_type=infra_type,
)
exhibit_docx = os.path.join(
work_dir,
f"robocall_mitigation_program_{order_number}_{date_str}.docx",
)
exhibit_result = generate_exhibit_a(
entity_name=entity_name,
frn=frn,
carrier_role=carrier_role,
carrier_metadata=carrier_metadata,
upstream_provider_name=upstream_provider_name,
llm_generate=self._call_llm if True else None,
output_path=exhibit_docx,
)
if exhibit_result:
generated_files.append(exhibit_result)
try:
generated_files.append(self._convert_to_pdf(exhibit_result))
except Exception as exc:
logger.warning("Exhibit A PDF conversion failed: %s", exc)
# ── 3. CPNI Certification Letter ─────────────────────────
from scripts.document_gen.templates.cpni_cert_letter_generator import (
generate_cpni_cert_letter,
)
cpni_docx = os.path.join(
work_dir, f"cpni_certification_{order_number}_{date_str}.docx"
)
cpni_result = generate_cpni_cert_letter(
entity_name=entity_name,
frn=frn,
filer_id_499=filer_id,
address_street=address_street,
address_city=address_city,
address_state=address_state,
address_zip=address_zip,
officer_name=ceo_name or contact_name,
officer_title=ceo_title,
contact_email=contact_email,
contact_phone=contact_phone,
complaints_count=0,
is_wholesale=is_wholesale,
output_path=cpni_docx,
)
if cpni_result:
generated_files.append(cpni_result)
try:
generated_files.append(self._convert_to_pdf(cpni_result))
except Exception as exc:
logger.warning("CPNI letter PDF conversion failed: %s", exc)
# ── 4. 499-A Filing Prep Checklist ───────────────────────
from scripts.document_gen.templates.fcc_499a_checklist_generator import (
generate_499a_checklist,
)
checklist_docx = os.path.join(
work_dir, f"fcc_499a_checklist_{order_number}_{date_str}.docx"
)
checklist_result = generate_499a_checklist(
entity_name=entity_name,
frn=frn,
filer_id_499=filer_id,
address_street=address_street,
address_city=address_city,
address_state=address_state,
address_zip=address_zip,
filer_type=carrier_category,
infra_type=infra_type,
service_categories=service_categories,
is_deminimis=is_deminimis,
is_lire=is_lire,
total_revenue_cents=total_revenue_cents,
interstate_pct=interstate_pct,
international_pct=international_pct,
last_filing_year=last_filing_year,
output_path=checklist_docx,
)
if checklist_result:
generated_files.append(checklist_result)
try:
generated_files.append(self._convert_to_pdf(checklist_result))
except Exception as exc:
logger.warning("499-A checklist PDF conversion failed: %s", exc)
# ── 5. Compliance Status Report ──────────────────────────
template_path = self._get_template_path()
report_docx = os.path.join(
work_dir, self._output_filename(order_number, "docx")
)
# Build template variables from checks and entity data
variables = self._build_report_variables(
order_number=order_number,
entity_name=entity_name,
frn=frn,
filer_id=filer_id,
checks=checks,
carrier_category=carrier_category,
infra_type=infra_type,
is_wholesale=is_wholesale,
uses_ucaas_provider=uses_ucaas_provider,
carrier_metadata=carrier_metadata,
stir_shaken_status=stir_shaken_status,
is_deminimis=is_deminimis,
generated_files=generated_files,
customer_name=order_data.get("customer_name", entity_name),
)
self._fill_template(template_path, variables, report_docx)
generated_files.append(report_docx)
try:
generated_files.append(self._convert_to_pdf(report_docx))
except Exception as exc:
logger.warning("Compliance report PDF conversion failed: %s", exc)
# ── 6. Compute recommended remediation slugs ─────────────────────
# Any check that came back red or yellow triggers its corresponding
# remediation slug. If 3+ trigger, we also include the full bundle
# slug (consumed by the recommendations endpoint to offer the
# bundled upsell at the 15% rate — see compliance-orders.ts:237).
recommended: list[str] = []
for check_id, slug in _CHECK_TO_SLUG.items():
for c in checks.get("checks", []):
if c.get("id") == check_id and c.get("status") in ("red", "yellow"):
recommended.append(slug)
break
if len(recommended) >= 3:
recommended.append("fcc-full-compliance")
# Persist onto the PG compliance_orders row so the recommendations
# API endpoint can serve it without re-running the checks.
self._persist_recommendations(order_number, recommended)
# ── 7. Re-render the PDF report with the bundle URL appended ────
# The checkup PDF is a takeaway artifact — customers who read it
# offline should get the same one-click remediation link as the
# delivery email. Append it in the appendix section we just wrote.
if recommended:
self._append_bundle_link_to_report(
report_docx=report_docx,
generated_files=generated_files,
order_number=order_number,
recommended=recommended,
customer_email=order_data.get("customer_email", ""),
customer_name=order_data.get("customer_name", ""),
)
return generated_files
# ------------------------------------------------------------------ #
# PDF appendix — append the bundle URL
# ------------------------------------------------------------------ #
def _append_bundle_link_to_report(
self,
*,
report_docx: str,
generated_files: list[str],
order_number: str,
recommended: list[str],
customer_email: str,
customer_name: str,
) -> None:
"""Add a 'One-click remediation' paragraph + bundle URL to the DOCX.
Only called when the PDF has already been converted; we update the
DOCX and reconvert so both formats carry the link. Failures are
non-fatal the customer still gets the report (just without the
deep-link).
"""
from urllib.parse import urlencode
try:
from docx import Document
site_base = os.environ.get("SITE_URL", "https://performancewest.net")
bundle_slugs = [s for s in recommended if s != "fcc-full-compliance"]
if len(bundle_slugs) < 2:
# Single-item — link straight to the /order/{slug} page.
slug = bundle_slugs[0] if bundle_slugs else ""
qs = urlencode({
"email": customer_email,
"name": customer_name,
"source": f"checkup:{order_number}",
})
url = f"{site_base}/order/{slug}?{qs}" if slug else ""
else:
qs_pairs = [
("email", customer_email),
("name", customer_name),
("source", f"checkup:{order_number}"),
] + [("service", s) for s in bundle_slugs]
url = f"{site_base}/order/compliance-bundle?{urlencode(qs_pairs)}"
if not url:
return
doc = Document(report_docx)
doc.add_paragraph("")
doc.add_heading("One-Click Remediation", level=2)
doc.add_paragraph(
"Fix the items flagged above in one checkout — the bundle "
"link below applies the automatic 15% discount when two "
"or more remediation services are selected."
)
doc.add_paragraph(url)
doc.save(report_docx)
# Reconvert PDF so the delivery email attachment matches.
try:
new_pdf = self._convert_to_pdf(report_docx)
# Replace the previous PDF path in generated_files if
# present (paths are identical — same stem, same dir) but
# the file has been rewritten on disk.
if new_pdf not in generated_files:
generated_files.append(new_pdf)
except Exception as exc:
logger.warning(
"FCCComplianceCheckup: could not reconvert PDF after "
"appending bundle link: %s",
exc,
)
except Exception as exc:
logger.warning(
"FCCComplianceCheckup: could not append bundle link to "
"report DOCX: %s",
exc,
)
# ------------------------------------------------------------------ #
# Recommendations persistence
# ------------------------------------------------------------------ #
def _persist_recommendations(
self, order_number: str, recommended_slugs: list[str]
) -> None:
"""Store recommended_slugs on compliance_orders (migration 047)."""
if not recommended_slugs:
return
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
cur.execute(
"UPDATE compliance_orders SET recommended_slugs = %s "
"WHERE order_number = %s",
(recommended_slugs, order_number),
)
conn.commit()
conn.close()
logger.info(
"FCCComplianceCheckup: persisted %d recommendations for %s: %s",
len(recommended_slugs),
order_number,
recommended_slugs,
)
except Exception as exc:
logger.warning(
"FCCComplianceCheckup: could not persist recommendations for %s: %s",
order_number,
exc,
)
def _build_report_variables(
self,
*,
order_number: str,
entity_name: str,
frn: str,
filer_id: str,
checks: dict,
carrier_category: str,
infra_type: str,
is_wholesale: bool,
uses_ucaas_provider: bool,
carrier_metadata: dict,
stir_shaken_status: str,
is_deminimis: bool,
generated_files: list[str],
customer_name: str,
) -> dict[str, str]:
"""Build the template variable dict for the compliance report."""
now = datetime.now()
# Helper to extract check status
def _check(check_id: str, field: str = "status") -> str:
for c in checks.get("checks", []):
if c.get("id") == check_id:
return c.get(field, "unknown")
return "unknown"
def _check_detail(check_id: str) -> str:
for c in checks.get("checks", []):
if c.get("id") == check_id:
return c.get("detail", "No data available.")
return "No data available."
# Status emoji mapping for report
def _status_label(status: str) -> str:
return {
"green": "COMPLIANT",
"yellow": "ATTENTION NEEDED",
"red": "NON-COMPLIANT",
"unknown": "UNABLE TO VERIFY",
}.get(status, status.upper())
# Overall posture
statuses = [_check(cid) for cid in [
"cores_registration", "rmd_filing", "stir_shaken", "cpni", "form_499a",
]]
if "red" in statuses:
overall = "RED — Immediate action required on one or more filings."
elif "yellow" in statuses:
overall = "YELLOW — Some items require attention within 30 days."
elif all(s == "green" for s in statuses):
overall = "GREEN — All checked items appear compliant."
else:
overall = "MIXED — Some items could not be verified programmatically."
# Carrier classification display
category_labels = {
"interconnected_voip": "Interconnected VoIP",
"non_interconnected_voip": "Non-Interconnected VoIP",
"clec": "CLEC",
"ixc": "Interexchange Carrier",
"cmrs": "CMRS",
"other": "Other",
}
ucaas_display = "N/A"
if uses_ucaas_provider:
ucaas_display = carrier_metadata.get("ucaas_provider", "Yes (provider not specified)")
# Recommended actions
actions = []
if _check("cores_registration") == "red":
actions.append("[CRITICAL] Register with FCC CORES and obtain an FRN immediately.")
if _check("rmd_filing") in ("red", "yellow"):
actions.append("[HIGH] File or recertify in the Robocall Mitigation Database — use the attached RMD letter.")
if _check("stir_shaken") in ("red", "yellow"):
actions.append("[HIGH] Address STIR/SHAKEN implementation gaps.")
if _check("cpni") in ("red", "yellow"):
actions.append("[MEDIUM] File annual CPNI certification (due March 1) — use the attached CPNI letter.")
if _check("form_499a") in ("red", "yellow"):
actions.append("[MEDIUM] File Form 499-A (due April 1) — review the attached preparation checklist.")
if not actions:
actions.append("No immediate actions required. Continue to monitor filing deadlines.")
# Appendix
doc_names = [Path(f).name for f in generated_files if f.endswith(".pdf")]
appendix = "\n".join(f"- {name}" for name in doc_names) if doc_names else "No documents attached."
return {
"order_number": order_number,
"customer_name": customer_name,
"entity_name": entity_name,
"frn": frn or "Not on file",
"date": now.strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
# Executive summary
"executive_summary": (
f"This compliance checkup was conducted for {entity_name} "
f"(FRN: {frn or 'pending'}) on {now.strftime('%B %d, %Y')}. "
f"Overall compliance posture: {overall}"
),
# CORES
"cores_status": _status_label(_check("cores_registration")),
"cores_red_light": _check_detail("cores_registration"),
"cores_entity_name": checks.get("entity", {}).get("name", entity_name),
"cores_address": checks.get("entity", {}).get("address", "Not available"),
"cores_detail": _check_detail("cores_registration"),
# RMD
"rmd_status": _status_label(_check("rmd_filing")),
"rmd_number": checks.get("rmd", {}).get("rmd_number", "Not on file"),
"rmd_last_cert": checks.get("rmd", {}).get("certification_date", "Unknown"),
"rmd_recert_due": _check("rmd_filing", "due_date") or "Unknown",
"rmd_removal_status": "Not removed" if not checks.get("removed") else "REMOVED — see detail",
"rmd_detail": _check_detail("rmd_filing"),
# STIR/SHAKEN
"stir_shaken_type": checks.get("rmd", {}).get("implementation_type", "Unknown"),
"stir_shaken_cert": stir_shaken_status.replace("_", " ").title(),
"stir_shaken_status": _status_label(_check("stir_shaken")),
"stir_shaken_detail": _check_detail("stir_shaken"),
# CPNI
"cpni_status": _status_label(_check("cpni")),
"cpni_due_date": "March 1",
"cpni_detail": _check_detail("cpni"),
# 499-A
"f499a_status": _status_label(_check("form_499a")),
"f499a_due_date": "April 1",
"f499a_filer_id": filer_id or "Not on file",
"f499a_detail": _check_detail("form_499a"),
# 499-Q
"f499q_detail": (
"De minimis provider — exempt from quarterly 499-Q filings."
if is_deminimis
else "Quarterly filings due: February 1, May 1, August 1, November 1. "
"Status: " + _status_label(_check("form_499q"))
),
# Classification
"carrier_category": category_labels.get(carrier_category, carrier_category),
"infra_type": infra_type.replace("_", " ").title() if infra_type else "Not classified",
"is_wholesale": "Yes" if is_wholesale else "No",
"ucaas_provider": ucaas_display,
"classification_stir_shaken": stir_shaken_status.replace("_", " ").title(),
"is_deminimis": "Yes" if is_deminimis else "No",
# Actions
"recommended_actions": "\n".join(actions),
# Appendix
"appendix_index": appendix,
}

View file

@ -0,0 +1,88 @@
"""FCC Full Compliance Bundle.
Composes the four individual remediation handlers in sequence:
1. RMD Registration / Recertification
2. CPNI Annual Certification
3. STIR/SHAKEN Implementation Assistance (which itself runs the RMD
flow with the updated posture, plus opens the STI-CA ToDo)
4. FCC Form 499-A + 499-Q Bundle
Each sub-handler is idempotent (see
``scripts/workers/services/telecom/filing_state.py``), so running this
bundle when some filings are already current only produces net-new
submissions.
"""
from __future__ import annotations
import logging
from .base_handler import BaseServiceHandler
from .rmd_filing import RMDFilingHandler
from .cpni_certification import CPNIFilingHandler
from .stir_shaken import StirShakenHandler
from .form_499a import Form499ABundleHandler
logger = logging.getLogger(__name__)
class FullComplianceHandler(BaseServiceHandler):
SERVICE_SLUG = "fcc-full-compliance"
SERVICE_NAME = "FCC Full Compliance Bundle"
REQUIRES_LLM = False
# In run order. Each sub-handler returns its own generated files; we
# concatenate them.
SUB_HANDLERS = (
RMDFilingHandler,
CPNIFilingHandler,
StirShakenHandler,
Form499ABundleHandler,
)
async def process(self, order_data: dict) -> list[str]:
generated: list[str] = []
order_number = order_data.get("name", "")
for cls in self.SUB_HANDLERS:
sub = cls()
try:
files = await sub.process(order_data)
if files:
generated.extend(files)
logger.info(
"FullComplianceHandler: %s produced %d file(s) for %s",
cls.__name__, len(files or []), order_number,
)
except Exception as exc:
logger.exception(
"FullComplianceHandler: %s failed for %s: %s",
cls.__name__, order_number, exc,
)
# Record the failure on an admin ToDo but keep going —
# a failure in one sub-handler should not block the rest.
self._record_sub_failure(order_number, cls.__name__, exc)
return generated
def _record_sub_failure(
self, order_number: str, sub_name: str, exc: Exception
) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n"
f"Sub-handler {sub_name} failed: {exc}. The other sub-handlers "
f"ran to completion; this one needs manual follow-up."
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as erp_exc:
logger.error("Could not create sub-failure ToDo: %s", erp_exc)

View file

@ -0,0 +1,298 @@
"""
Flowroute Canadian DID provisioning service.
Production client for searching and purchasing BC DIDs via the Flowroute API.
Called by the CRTC pipeline after domain provisioning.
Environment variables:
FLOWROUTE_ACCESS_KEY 743f4c49
FLOWROUTE_SECRET_KEY 0b10c6d5...
Usage:
from scripts.workers.services.flowroute import provision_bc_did
result = provision_bc_did(order_number="CA-2026-XXXXX")
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import requests
LOG = logging.getLogger("workers.flowroute")
API_BASE = "https://api.flowroute.com"
# BC area codes in preference order
BC_AREA_CODES = ["1604", "1778", "1236", "1250"]
_access_key = os.environ.get("FLOWROUTE_ACCESS_KEY", "")
_secret_key = os.environ.get("FLOWROUTE_SECRET_KEY", "")
def _session() -> requests.Session:
s = requests.Session()
s.auth = (_access_key, _secret_key)
s.headers["Accept"] = "application/json"
return s
def ping() -> bool:
"""Verify API credentials."""
try:
s = _session()
r = s.get(f"{API_BASE}/v2/numbers", params={"limit": 1}, timeout=10)
ok = r.status_code == 200
LOG.info("Flowroute ping: %s", "OK" if ok else f"FAIL {r.status_code}")
return ok
except Exception as e:
LOG.error("Flowroute ping failed: %s", e)
return False
def search_available_dids(
starts_with: str = "",
limit: int = 5,
number_type: str = "standard",
) -> list[dict]:
"""
Search for available Canadian DIDs across all BC area codes.
Tries 604 778 236 250 and stops at the first code with results.
Returns: List of {"did": "16045551234", "rate_center": "...", "monthly_cost": "..."}
"""
s = _session()
prefixes = [starts_with] if starts_with else BC_AREA_CODES
for prefix in prefixes:
try:
r = s.get(
f"{API_BASE}/v2/numbers/available",
params={"starts_with": prefix, "limit": limit, "number_type": number_type},
timeout=15,
)
if r.status_code != 200:
LOG.warning("Flowroute search %s: HTTP %d", prefix, r.status_code)
continue
results = []
for item in r.json().get("data", []):
attrs = item.get("attributes", {})
results.append({
"did": item.get("id", ""),
"rate_center": attrs.get("rate_center", ""),
"state": attrs.get("state", ""),
"monthly_cost": attrs.get("monthly_cost", ""),
})
if results:
LOG.info("Flowroute search '%s': %d results", prefix, len(results))
return results
LOG.info("Flowroute search '%s': 0 results", prefix)
except Exception as e:
LOG.error("Flowroute search error for %s: %s", prefix, e)
LOG.warning("No BC DIDs available in any area code")
return []
def purchase_did(did: str) -> dict:
"""
Purchase a DID from Flowroute.
Args:
did: Phone number to purchase (e.g. "16045551234")
Returns:
{"success": bool, "did": str, "monthly_cost": str, "error": str}
"""
s = _session()
try:
r = s.post(f"{API_BASE}/v2/numbers/{did}", timeout=15)
if r.status_code in (200, 201):
LOG.info("Flowroute: purchased DID %s", did)
return {"success": True, "did": did}
else:
msg = r.text[:200]
LOG.error("Flowroute purchase failed for %s: %d %s", did, r.status_code, msg)
return {"success": False, "did": did, "error": msg}
except Exception as e:
LOG.error("Flowroute purchase error for %s: %s", did, e)
return {"success": False, "did": did, "error": str(e)}
def create_route(
value: str,
route_type: str = "host",
alias: str = "",
) -> Optional[str]:
"""
Create a SIP route on Flowroute.
Args:
value: SIP URI or IP address (e.g. "sip:trunk@pbx.example.com" or "203.0.113.50")
route_type: "host" (SIP) Flowroute only supports host routes
alias: Friendly name for the route
Returns: Route ID string, or None on failure
"""
s = _session()
try:
r = s.post(f"{API_BASE}/v2/routes", json={
"data": {
"type": "route",
"attributes": {
"route_type": route_type,
"value": value,
"alias": alias,
},
},
}, timeout=15)
if r.status_code in (200, 201):
route_id = r.json()["data"]["id"]
LOG.info("Flowroute: created route %s%s (id=%s)", alias, value, route_id)
return route_id
else:
LOG.error("Flowroute create route failed: %d %s", r.status_code, r.text[:200])
return None
except Exception as e:
LOG.error("Flowroute create route error: %s", e)
return None
def set_primary_route(did: str, route_id: str) -> bool:
"""Assign a route as the primary route for a DID."""
s = _session()
# Strip leading + if present
clean_did = did.lstrip("+")
try:
r = s.patch(
f"{API_BASE}/v2/numbers/{clean_did}/relationships/primary_route",
json={"data": {"type": "route", "id": route_id}},
timeout=15,
)
if r.status_code == 204:
LOG.info("Flowroute: set primary route for %s → route %s", did, route_id)
return True
else:
LOG.error("Flowroute set route failed for %s: %d %s", did, r.status_code, r.text[:200])
return False
except Exception as e:
LOG.error("Flowroute set route error for %s: %s", did, e)
return False
def configure_routing(
did: str,
routing_type: str = "later",
forward_number: str = "",
sip_uri: str = "",
sip_ip: str = "",
order_number: str = "",
) -> dict:
"""
Configure call routing for a purchased DID.
Flowroute uses SIP "host" routes for all routing. For call forwarding
to a PSTN number, the forward number is set as the SIP route value
(Flowroute handles the SIPPSTN gateway internally).
Args:
did: The DID to configure (e.g. "+16045551234")
routing_type: "forward", "sip", or "later"
forward_number: Phone number to forward to (for routing_type="forward")
sip_uri: SIP URI (for routing_type="sip")
sip_ip: IP address (for routing_type="sip", fallback)
order_number: For logging context
Returns:
{"success": bool, "route_id": str, "error": str}
"""
if routing_type == "later":
LOG.info("[%s] DID %s routing set to 'later' — no routing configured", order_number, did)
return {"success": True, "route_id": "", "note": "Customer will configure routing later"}
if routing_type == "forward" and forward_number:
# Create a host route with the forward number
# Flowroute routes PSTN calls when value is a phone number
clean_fwd = forward_number.lstrip("+").replace("-", "").replace(" ", "")
route_id = create_route(
value=clean_fwd,
alias=f"fwd-{order_number}-{clean_fwd[-4:]}",
)
if route_id:
if set_primary_route(did, route_id):
LOG.info("[%s] DID %s forwarding to %s", order_number, did, forward_number)
return {"success": True, "route_id": route_id}
return {"success": False, "route_id": route_id, "error": "Route created but could not assign to DID"}
return {"success": False, "route_id": "", "error": "Could not create forward route"}
if routing_type == "sip":
# Use SIP URI if provided, fall back to IP
value = sip_uri or sip_ip
if not value:
return {"success": False, "route_id": "", "error": "No SIP URI or IP provided"}
route_id = create_route(
value=value,
alias=f"sip-{order_number}",
)
if route_id:
if set_primary_route(did, route_id):
LOG.info("[%s] DID %s routed to SIP %s", order_number, did, value)
return {"success": True, "route_id": route_id}
return {"success": False, "route_id": route_id, "error": "Route created but could not assign to DID"}
return {"success": False, "route_id": "", "error": "Could not create SIP route"}
return {"success": False, "route_id": "", "error": f"Unknown routing type: {routing_type}"}
def release_did(did: str) -> bool:
"""Release a purchased DID (for test cleanup)."""
s = _session()
try:
r = s.delete(f"{API_BASE}/v2/numbers/{did}", timeout=15)
return r.status_code in (200, 204)
except Exception:
return False
def provision_bc_did(order_number: str = "") -> dict:
"""
Full BC DID provisioning workflow:
1. Search for available BC DIDs (tries 604 778 236 250)
2. Purchase the first available one
3. Return the provisioned DID
Args:
order_number: For logging context
Returns:
{"success": bool, "did": str, "rate_center": str, "monthly_cost": str, "error": str}
"""
LOG.info("[%s] Provisioning BC DID...", order_number)
# Step 1: Search
available = search_available_dids(limit=3)
if not available:
return {"success": False, "did": "", "error": "No BC DIDs available in any area code"}
# Step 2: Purchase the first available
target = available[0]
result = purchase_did(target["did"])
if result["success"]:
# Format as +1XXXXXXXXXX
did = target["did"]
if not did.startswith("+"):
did = f"+{did}"
result["did"] = did
result["rate_center"] = target.get("rate_center", "")
result["monthly_cost"] = target.get("monthly_cost", "")
LOG.info("[%s] Provisioned DID: %s (%s)", order_number, did, target.get("rate_center"))
return result

View file

@ -0,0 +1,126 @@
"""FLSA Compliance Audit handler (LLM-based).
Generates a Fair Labor Standards Act compliance audit report including
employee classification analysis, overtime review, recordkeeping assessment,
and a prioritized remediation plan.
"""
from __future__ import annotations
import os
from pathlib import Path
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating a Fair Labor Standards Act (FLSA) compliance audit report.
RULES:
- Write in professional, clear business English
- Cite specific FLSA regulations (29 CFR § 541, etc.)
- Never provide legal advice use "we recommend" not "you must"
- For each finding: what was found, regulation, risk level (Low/Medium/High/Critical), remediation
- Structure with clear headings and bullet points
- Include specific section references from the Fair Labor Standards Act
- Reference DOL Fact Sheets where applicable
- Note state-specific requirements where the client operates
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the FLSA audit findings. "
"Include the scope of the audit, number of positions reviewed, "
"overall compliance posture, and highest-priority findings."
),
},
{
"name": "classification_analysis",
"prompt": (
"Analyze each employee classification (exempt vs non-exempt) "
"against the duties tests under 29 CFR § 541. For each role: "
"state the current classification, whether it meets the salary "
"basis test ($684/week), whether it passes the duties test for "
"the claimed exemption (executive, administrative, professional, "
"computer, outside sales), and the risk if misclassified."
),
},
{
"name": "overtime_analysis",
"prompt": (
"Analyze overtime calculation methods and identify any violations. "
"Review the regular rate of pay calculations, treatment of bonuses "
"and commissions in overtime, comp time practices, fluctuating "
"workweek usage, and any off-the-clock work risks."
),
},
{
"name": "recordkeeping_review",
"prompt": (
"Review timekeeping and recordkeeping compliance under 29 CFR § 516. "
"Assess: accuracy of time records, retention periods, required data "
"fields, break/meal period documentation, and any gaps that could "
"expose the organization in a wage-hour investigation."
),
},
{
"name": "youth_employment",
"prompt": (
"Review compliance with child labor provisions under FLSA § 212-213. "
"If the client employs any workers under 18: permitted occupations, "
"hour restrictions, hazardous occupation orders, and required "
"documentation. If not applicable, state so briefly."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized remediation plan for all findings. "
"For each item: finding reference, risk level, recommended action, "
"responsible party, and suggested timeline. Group by priority "
"(Critical → High → Medium → Low)."
),
},
]
class FLSAAuditHandler(BaseServiceHandler):
SERVICE_SLUG = "flsa-audit"
SERVICE_NAME = "FLSA Compliance Audit"
TEMPLATE_NAME = "flsa_audit_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
# 1. Load template and fill basic variables
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
# 2. Generate LLM sections
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
# 3. Append sections to the document
self._add_sections_to_doc(docx_path, sections)
# 4. Convert to PDF
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,256 @@
"""Foreign Carrier Affiliation Notification handler (47 CFR § 63.11).
Section 63.11 requires U.S. carriers affiliated with a foreign carrier
serving the same route to/from the United States to file a notification
with the FCC's International Bureau. The notification goes to ECFS /
IBFS scaffolding here uses the ECFS Express upload path (same as CPNI
and a few other proceedings).
Intake fields (intake_data.foreign_carrier):
foreign_carrier_legal_name: str
country: str (ISO-2)
ownership_pct: float
affected_routes: list[str] # ISO-2 country codes
affiliation_date: str # YYYY-MM-DD
notification_type: str # "pre-consummation" | "post-closing"
Rare-enough filing that we generate the notification letter + auto-submit
when the auto-filing toggle is on, but we prefer admin review on this one
because a miscategorized affiliation is costly.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Optional
from .base_handler import BaseServiceHandler
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay, type_slowly
logger = logging.getLogger(__name__)
ECFS_UPLOAD_URL = os.environ.get(
"FCC_ECFS_UPLOAD_URL", "https://www.fcc.gov/ecfs/upload/express",
)
# IB Docket 99-217 is the historical home; current filings often use IB
# Docket 04-47 (Section 63.10/63.11 proceedings). Override via env for
# deployment-specific needs.
FCC_63_11_DOCKET = os.environ.get("FCC_63_11_DOCKET", "04-47")
class ForeignCarrierAffiliationHandler(BaseServiceHandler):
SERVICE_SLUG = "fcc-63-11-notification"
SERVICE_NAME = "Foreign Carrier Affiliation Notification (47 CFR § 63.11)"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
intake = order_data.get("intake_data") or {}
fc_intake = intake.get("foreign_carrier") or {}
entity_id = entity.get("id")
generated: list[str] = []
required = ["foreign_carrier_legal_name", "country",
"ownership_pct", "affected_routes", "affiliation_date"]
missing = [k for k in required if not fc_intake.get(k)]
if missing:
self._create_admin_todo(
order_number,
f"63.11 notification requires intake_data.foreign_carrier to "
f"carry: {missing}. Ask the customer to complete intake.",
)
return generated
# Generate the notification letter
letter_path = self._write_letter(
order_number=order_number, entity=entity,
fc_intake=fc_intake, work_dir=work_dir,
)
if letter_path:
generated.append(letter_path)
try:
generated.append(self._convert_to_pdf(letter_path))
except Exception as exc:
logger.warning("63.11 letter PDF conversion failed: %s", exc)
decision = check_auto_filing(order_data)
if not decision.may_submit:
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in generated],
admin_email=decision.admin_email,
summary=(
f"47 CFR § 63.11 affiliation notification.\n"
f"Foreign carrier: {fc_intake['foreign_carrier_legal_name']} "
f"({fc_intake['country']}).\n"
f"Ownership: {fc_intake['ownership_pct']}%.\n"
f"Affected routes: {', '.join(fc_intake.get('affected_routes', []))}.\n"
f"Affiliation date: {fc_intake['affiliation_date']}."
),
)
return generated
# Auto-submit via ECFS
conf_path, conf_num = await self._submit_to_ecfs(
order_number=order_number, entity=entity, letter_pdf=next(
(p for p in generated if p.endswith(".pdf")), letter_path,
), work_dir=work_dir,
)
if conf_path:
generated.append(conf_path)
if entity_id and conf_num:
self._persist_affiliation(entity_id, fc_intake, conf_num)
return generated
# ------------------------------------------------------------------ #
def _write_letter(
self, *, order_number: str, entity: dict, fc_intake: dict, work_dir: str,
) -> str:
from docx import Document
doc = Document()
doc.add_heading(
"Notification of Foreign Carrier Affiliation "
"(47 CFR § 63.11)", level=1,
)
doc.add_paragraph(
f"Filed: {datetime.now().strftime('%B %d, %Y')}"
)
doc.add_paragraph(
f"Filing party: {entity.get('legal_name', '')} "
f"(FRN {entity.get('frn', 'N/A')})"
)
doc.add_paragraph(
f"To: Federal Communications Commission — International Bureau"
)
doc.add_paragraph("")
doc.add_paragraph(
f"Pursuant to 47 CFR § 63.11, {entity.get('legal_name', '')} "
f"notifies the Commission of an affiliation with a foreign "
f"carrier as follows:"
)
doc.add_paragraph(
f"Foreign carrier legal name: {fc_intake['foreign_carrier_legal_name']}"
)
doc.add_paragraph(
f"Country / jurisdiction of foreign carrier: {fc_intake['country']}"
)
doc.add_paragraph(
f"Ownership interest: {fc_intake['ownership_pct']}%"
)
doc.add_paragraph(
f"Affected route(s): {', '.join(fc_intake.get('affected_routes', []))}"
)
doc.add_paragraph(
f"Affiliation date: {fc_intake['affiliation_date']} "
f"({fc_intake.get('notification_type', 'post-closing')})"
)
doc.add_paragraph("")
doc.add_paragraph(
"This notification is submitted in accordance with the "
"requirements and timing specified in 47 CFR § 63.11(a) "
"and 63.11(b). The filing party certifies that the "
"information provided is true and correct to the best of its "
"knowledge."
)
for _ in range(2):
doc.add_paragraph("")
doc.add_paragraph("_" * 45)
doc.add_paragraph(entity.get("ceo_name") or entity.get("contact_name", ""))
doc.add_paragraph(entity.get("ceo_title", "Chief Executive Officer"))
doc.add_paragraph(entity.get("legal_name", ""))
out = os.path.join(work_dir, f"fcc_63_11_letter_{order_number}.docx")
doc.save(out)
return out
async def _submit_to_ecfs(
self, *, order_number: str, entity: dict,
letter_pdf: str, work_dir: str,
) -> tuple[str | None, str]:
conf_path = os.path.join(work_dir, f"ecfs_63_11_confirmation_{order_number}.pdf")
confirmation = ""
try:
async with undetected_browser(headless=True) as (ctx, page):
await page.goto(ECFS_UPLOAD_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
await type_slowly(page, 'input[name="proceedings"]', FCC_63_11_DOCKET)
await page.wait_for_selector(f'li:has-text("{FCC_63_11_DOCKET}")', timeout=10000)
await page.click(f'li:has-text("{FCC_63_11_DOCKET}")')
await human_delay()
await type_slowly(page, 'input[name="name_of_filer"]', entity.get("legal_name", ""))
await type_slowly(page, 'input[name="filer_email"]', entity.get("contact_email", ""))
await page.select_option('select[name="type_of_filing"]', label="Notification")
await page.set_input_files('input[type="file"]', letter_pdf)
await human_delay(1.0, 2.0)
await page.click('button:has-text("Continue")')
await page.wait_for_selector("text=Review", timeout=30000)
await page.click('button:has-text("Submit")')
await page.wait_for_selector("text=Confirmation", timeout=60000)
body = await page.locator("body").inner_text()
for line in body.splitlines():
if "Filing ID" in line or "Confirmation" in line:
parts = line.split(":", 1)
if len(parts) == 2 and parts[1].strip():
confirmation = parts[1].strip()
break
await page.pdf(path=conf_path, format="Letter")
return conf_path, confirmation
except Exception as exc:
logger.exception("63.11 ECFS submission failed: %s", exc)
self._create_admin_todo(
order_number,
f"63.11 ECFS submission raised: {exc}. Packet is in MinIO; "
f"file manually at https://www.fcc.gov/ecfs/ under docket {FCC_63_11_DOCKET}.",
)
return None, ""
def _persist_affiliation(self, entity_id: int, fc_intake: dict,
confirmation: str) -> None:
try:
import json
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
record = dict(fc_intake)
record["filed_at"] = datetime.utcnow().isoformat()
record["ecfs_confirmation"] = confirmation
with conn.cursor() as cur:
cur.execute(
"""
UPDATE telecom_entities
SET foreign_affiliations = COALESCE(foreign_affiliations, '[]'::jsonb)
|| %s::jsonb
WHERE id = %s
""",
(json.dumps([record]), entity_id),
)
conn.commit()
conn.close()
except Exception as exc:
logger.warning("Could not persist affiliation on %s: %s", entity_id, exc)
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}",
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

View file

@ -0,0 +1,329 @@
"""Foreign Qualification (Certificate of Authority) handler.
Processes per-state COA filings for entities expanding to additional
states. Supports two modes:
- `foreign-qualification-single`: one target state per order.
- `foreign-qualification-multi`: N target states, one order. The
handler fans out one `foreign_qualification_registrations` row per
state and processes them sequentially.
FCC carriers ordering multi-state registration get the same flow
their entity is identified by telecom_entity_id, the handler reads
the selected target states from intake_data.target_states.
Filing steps per state (high-level):
1. Obtain Certificate of Good Standing from home state (if required
by target state's `foreign_qual_requires_coa` flag).
2. Provision NW Registered Agent address in target state (if the
order includes RA service).
3. Submit the Certificate of Authority / Application for Registration
via the target state's SOS portal (Playwright adapter).
4. Capture confirmation, persist COA document.
5. Mark `foreign_qualification_registrations.status = 'approved'`.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Optional
import psycopg2
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
DATABASE_URL = os.environ.get("DATABASE_URL", "")
class ForeignQualificationHandler(BaseServiceHandler):
SERVICE_SLUG = "foreign-qualification"
SERVICE_NAME = "Foreign Qualification"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
order_number = order_data["name"]
entity = order_data.get("entity", {}) or {}
intake = order_data.get("intake_data", {}) or {}
home_state = (
intake.get("home_state_code")
or entity.get("state_of_formation")
or entity.get("address_state")
or ""
).upper()
entity_type = (
intake.get("entity_type")
or entity.get("entity_type")
or "llc"
).lower()
target_states = intake.get("target_states") or []
if isinstance(target_states, str):
target_states = [s.strip().upper() for s in target_states.split(",") if s.strip()]
else:
target_states = [str(s).strip().upper() for s in target_states]
if not target_states:
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: no target states specified in intake_data. "
"Admin should add target_states and re-dispatch.",
)
return []
entity_name = (
intake.get("entity_legal_name")
or entity.get("legal_name")
or ""
)
if not entity_name:
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: missing entity legal name. "
"Admin should set entity_legal_name in intake_data.",
)
return []
# ── Fan out: one registration row per target state ──────────────
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor() as cur:
for state_code in target_states:
# Fetch state fee from state_filing_fees.
fee_col = (
"foreign_llc_fee"
if entity_type in ("llc", "pllc")
else "foreign_corp_fee"
)
cur.execute(
f"SELECT {fee_col} AS fee FROM state_filing_fees WHERE state_code = %s",
(state_code,),
)
fee_row = cur.fetchone()
state_fee = int(fee_row[0]) if fee_row and fee_row[0] else 0
# Check for existing active registration to avoid dupes.
cur.execute(
"""
SELECT id FROM foreign_qualification_registrations
WHERE order_number = %s
AND target_state_code = %s
AND status NOT IN ('cancelled','rejected')
LIMIT 1
""",
(order_number, state_code),
)
if cur.fetchone():
logger.info(
"ForeignQualHandler: %s already has active registration "
"for %s — skipping",
order_number, state_code,
)
continue
# Compliance order ID (nullable).
co_id = order_data.get("compliance_order_id") or order_data.get("id")
cur.execute(
"""
INSERT INTO foreign_qualification_registrations (
compliance_order_id, order_number,
telecom_entity_id,
home_country, home_state_code,
target_state_code,
entity_legal_name, entity_type,
formed_on, home_state_filing_number, ein,
principal_address_json,
include_ra_service,
state_fee_cents, expedited,
status
) VALUES (
%s, %s,
%s,
%s, %s,
%s,
%s, %s,
%s, %s, %s,
%s::jsonb,
%s,
%s, %s,
'received'
)
""",
(
co_id, order_number,
entity.get("id"),
intake.get("home_country", "US"), home_state,
state_code,
entity_name, entity_type,
entity.get("formed_on"), entity.get("state_filing_number"),
entity.get("ein"),
_json_or_null(intake.get("principal_address")),
intake.get("include_ra_each", True),
state_fee,
bool(intake.get("expedited", False)),
),
)
conn.commit()
logger.info(
"ForeignQualHandler: created %d registration(s) for %s",
len(target_states), order_number,
)
except Exception as exc:
logger.exception(
"ForeignQualHandler: DB error creating registrations for %s: %s",
order_number, exc,
)
conn.rollback()
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: failed to create registration rows — {exc}",
)
return []
finally:
conn.close()
# ── Per-state filing dispatch ───────────────────────────────────
# For v1, each state filing needs admin review because we haven't
# built the state-specific Playwright adapters for foreign qual yet.
# The handler creates a ToDo per state so the admin can file manually
# or trigger the adapter once it's written.
filed_count = 0
for state_code in target_states:
try:
filed = await self._process_one_state(
order_number=order_number,
entity=entity,
intake=intake,
entity_name=entity_name,
entity_type=entity_type,
home_state=home_state,
target_state=state_code,
)
if filed:
filed_count += 1
except Exception as exc:
logger.error(
"ForeignQualHandler: error filing %s in %s: %s",
order_number, state_code, exc,
)
self._update_registration_status(
order_number, state_code, "admin_review",
last_error=str(exc),
)
if filed_count == 0:
logger.info(
"ForeignQualHandler: no states auto-filed for %s"
"all set to admin_review",
order_number,
)
return [] # artifacts come from per-state filings, not the parent
async def _process_one_state(
self,
*,
order_number: str,
entity: dict,
intake: dict,
entity_name: str,
entity_type: str,
home_state: str,
target_state: str,
) -> bool:
"""Attempt to file the COA in one target state. Returns True if
the adapter succeeded, False if we parked in admin_review."""
from scripts.formation.jurisdictions import get_jurisdiction
jc = get_jurisdiction(target_state)
if not jc.has_adapter():
logger.info(
"ForeignQualHandler: no adapter for %s — parking for admin",
target_state,
)
self._update_registration_status(
order_number, target_state, "admin_review",
last_error=f"No Playwright adapter for {target_state} foreign-qual",
)
self._create_admin_todo(
order_number,
f"Foreign qualification in {jc.name} ({target_state}) needs "
f"manual filing — no adapter. Entity: {entity_name} ({entity_type}) "
f"from {home_state}.",
)
return False
# TODO: dispatch to adapter.file_foreign_qualification() once
# per-state adapters implement the method. For now, all states
# park at admin_review.
self._update_registration_status(
order_number, target_state, "admin_review",
last_error="Foreign qual adapter not yet implemented",
)
self._create_admin_todo(
order_number,
f"Foreign qualification in {jc.name} ({target_state}): entity "
f"{entity_name} ({entity_type}) from {home_state}. "
f"State fee: ${jc.foreign_qualification_fee_cents(entity_type) or 0 / 100:.0f}. "
f"RA: {'NWRA requested' if intake.get('include_ra_each', True) else 'not requested'}. "
f"File via {jc.portal_name or 'portal'} at {jc.portal_url or 'N/A'}.",
)
return False
def _update_registration_status(
self,
order_number: str,
target_state: str,
status: str,
*,
last_error: Optional[str] = None,
) -> None:
try:
conn = psycopg2.connect(DATABASE_URL)
with conn.cursor() as cur:
cur.execute(
"""
UPDATE foreign_qualification_registrations
SET status = %s,
last_error = %s,
attempt_count = attempt_count + 1
WHERE order_number = %s
AND target_state_code = %s
""",
(status, last_error, order_number, target_state),
)
conn.commit()
conn.close()
except Exception as exc:
logger.error(
"ForeignQualHandler: failed to update status for %s/%s: %s",
order_number, target_state, exc,
)
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "Medium",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)
def _json_or_null(v) -> Optional[str]:
if v is None:
return None
import json
return json.dumps(v) if isinstance(v, dict) else str(v)

View file

@ -0,0 +1,320 @@
"""Form 499-A Initial (New Filer) Registration handler.
Per USAC rules, new telecommunications providers must register with the
Universal Service Administrative Company within 30 days of offering
service. The "New Filer Registration" path at forms.universalservice.org
creates a USAC Filer ID (812xxx) that every subsequent 499-A / 499-Q
filing references.
Distinct from our annual ``Form499AHandler`` new filers have no
revenue to report yet; we submit Blocks 1, 2, and 6 (identifying info,
contacts, officer certification) and skip revenue.
Flow:
1. Intake-driven Playwright session against
https://forms.universalservice.org "Register as New Filer"
2. Submit Blocks 1, 2-A, 2-B, 2-C, 6
3. Capture Filer ID from confirmation
4. Persist filer_id_499 on the entity
5. Schedule next April 1 in compliance_calendar
"""
from __future__ import annotations
import logging
import os
from datetime import date, datetime
from typing import Optional
from .base_handler import BaseServiceHandler
from .telecom import filing_state
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay, type_slowly
logger = logging.getLogger(__name__)
USAC_EFILE_URL = os.environ.get(
"USAC_EFILE_URL", "https://forms.universalservice.org/",
)
class Form499InitialHandler(BaseServiceHandler):
SERVICE_SLUG = "fcc-499-initial"
SERVICE_NAME = "Form 499 Initial Registration"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
if not entity.get("frn"):
self._create_admin_todo(
order_number,
"Form 499 Initial Registration requires an FRN. Order a "
"cores-frn-registration first, then re-dispatch this.",
)
return []
if entity.get("filer_id_499"):
logger.info(
"Form499InitialHandler: entity %s already has filer_id_499 %s"
"skipping",
entity_id, entity.get("filer_id_499"),
)
return []
decision = check_auto_filing(order_data)
if not decision.may_submit:
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[],
admin_email=decision.admin_email,
summary=(
"New-filer registration at USAC E-File. Blocks 1+2+6 only "
"(no revenue to report). Will assign Filer ID on success."
),
)
return []
filer_id, confirmation_path = await self._submit_new_filer(
order_number=order_number, entity=entity, work_dir=work_dir,
)
generated: list[str] = []
if confirmation_path:
generated.append(confirmation_path)
if filer_id and entity_id:
self._persist_filer_id(entity_id, filer_id)
self._schedule_first_499a(order_number, entity)
return generated
# ------------------------------------------------------------------ #
# USAC E-File flow — New Filer Registration
# ------------------------------------------------------------------ #
async def _submit_new_filer(
self, *, order_number: str, entity: dict, work_dir: str,
) -> tuple[str, Optional[str]]:
confirmation_path = os.path.join(
work_dir, f"usac_new_filer_confirmation_{order_number}.pdf",
)
try:
async with undetected_browser(headless=True) as (ctx, page):
await page.goto(USAC_EFILE_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
# Click into New Filer Registration
await page.click("text=Register as New Filer")
await human_delay()
# Block 1 — identifying info
await type_slowly(page, 'input[name="company_name"]', entity.get("legal_name", ""))
await page.fill('input[name="frn"]', entity.get("frn", ""))
if entity.get("dba_name"):
await page.fill('input[name="dba_name"]', entity.get("dba_name", ""))
if entity.get("ein"):
await page.fill('input[name="ein"]', entity.get("ein", "").replace("-", ""))
if entity.get("affiliated_filer_name"):
await page.fill('input[name="affiliated_filer_name"]',
entity["affiliated_filer_name"])
await page.fill('input[name="affiliated_filer_ein"]',
entity.get("affiliated_filer_ein", ""))
for i, tn in enumerate((entity.get("trade_names") or [])[:10]):
try: await page.fill(f'input[name="trade_name_{i}"]', tn)
except Exception: pass
# Line 105 — multi-select ranked categories
from .telecom.fcc_499_utils import all_line_105_boxes_to_tick
categories = entity.get("line_105_categories") or []
if not categories and entity.get("carrier_category"):
# Legacy single-category fallback
categories = [{"id": entity["carrier_category"], "rank": 1,
"infra_type": entity.get("infra_type", "facilities")}]
for box_num in all_line_105_boxes_to_tick(categories):
try: await page.check(f'input[name="line_105_box_{box_num}"]')
except Exception: pass
# Block 2-A — regulatory contact (entity overrides, else PW defaults)
await page.fill('input[name="regulatory_contact_name"]',
entity.get("regulatory_contact_name")
or entity.get("contact_name", ""))
await page.fill('input[name="regulatory_contact_email"]',
entity.get("regulatory_contact_email")
or entity.get("contact_email", ""))
await page.fill('input[name="regulatory_contact_phone"]',
entity.get("regulatory_contact_phone")
or entity.get("contact_phone", ""))
# Block 2-B — D.C. Agent
# Source priority: intake_data.dc_agent → entity columns → NWRA default
intake = order_data.get("intake_data", {}) or {}
dc = intake.get("dc_agent", {}) or {}
await page.fill('input[name="dc_agent_company"]',
dc.get("company")
or entity.get("dc_agent_company",
"Northwest Registered Agent Service Inc."))
await page.fill('input[name="dc_agent_street"]',
dc.get("street")
or entity.get("dc_agent_street", "1717 N Street NW STE 1"))
await page.fill('input[name="dc_agent_city"]',
dc.get("city")
or entity.get("dc_agent_city", "Washington"))
await page.fill('input[name="dc_agent_state"]',
dc.get("state")
or entity.get("dc_agent_state", "DC"))
await page.fill('input[name="dc_agent_zip"]',
dc.get("zip")
or entity.get("dc_agent_zip", "20036"))
# Block 2-C — Officers (up to 3, with business addresses)
for i in (1, 2, 3):
name = entity.get(f"officer_{i}_name") if i > 1 else (
entity.get("officer_1_name") or entity.get("ceo_name")
)
title = entity.get(f"officer_{i}_title") if i > 1 else (
entity.get("officer_1_title") or entity.get("ceo_title")
)
if not name:
continue
await page.fill(f'input[name="officer_{i}_name"]', name)
await page.fill(f'input[name="officer_{i}_title"]', title or "")
await page.fill(f'input[name="officer_{i}_street"]',
entity.get(f"officer_{i}_street", ""))
await page.fill(f'input[name="officer_{i}_city"]',
entity.get(f"officer_{i}_city", ""))
await page.fill(f'input[name="officer_{i}_state"]',
entity.get(f"officer_{i}_state", ""))
await page.fill(f'input[name="officer_{i}_zip"]',
entity.get(f"officer_{i}_zip", ""))
# Line 227: jurisdictions multi-select
for state in entity.get("jurisdictions_served") or []:
try: await page.check(f'input[name="line_227_state_{state}"]')
except Exception: pass
# Line 228: first-service date (or pre-1999 checkbox)
if entity.get("first_telecom_service_pre_1999"):
try: await page.check('input[name="line_228_pre_1999"]')
except Exception: pass
else:
if entity.get("first_telecom_service_year"):
await page.fill('input[name="line_228_year"]',
str(entity["first_telecom_service_year"]))
if entity.get("first_telecom_service_month"):
await page.fill('input[name="line_228_month"]',
str(entity["first_telecom_service_month"]))
# Block 6 — certification signature
sig_name = entity.get("officer_1_name") or entity.get("ceo_name") \
or entity.get("contact_name", "")
await page.fill('input[name="officer_signature_name"]', sig_name)
await page.check('input[name="officer_certification"]')
await human_delay(1.5, 3.0)
await page.click('button:has-text("Submit")')
await page.wait_for_selector("text=Filer ID", timeout=90000)
body = await page.locator("body").inner_text()
filer_id = ""
import re
m = re.search(r"\bFiler ID[:\s]*(\d{6,8})\b", body)
if m:
filer_id = m.group(1)
await page.pdf(path=confirmation_path, format="Letter")
if not filer_id:
self._create_admin_todo(
order_number,
"USAC new-filer submission completed but Filer ID could "
"not be extracted. Check the confirmation PDF in MinIO; "
"update telecom_entities.filer_id_499 manually.",
)
return "", confirmation_path
logger.info(
"Form499InitialHandler: assigned Filer ID %s for %s",
filer_id, entity.get("legal_name", ""),
)
return filer_id, confirmation_path
except Exception as exc:
logger.exception("Form499InitialHandler: USAC flow failed: %s", exc)
self._create_admin_todo(
order_number,
f"USAC new-filer registration raised: {exc}. Complete manually "
f"at {USAC_EFILE_URL} and update telecom_entities.filer_id_499.",
)
return "", None
# ------------------------------------------------------------------ #
# Persistence + calendar scheduling
# ------------------------------------------------------------------ #
def _persist_filer_id(self, entity_id: int, filer_id: str) -> None:
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with conn.cursor() as cur:
cur.execute(
"UPDATE telecom_entities SET filer_id_499=%s WHERE id=%s",
(filer_id, entity_id),
)
conn.commit()
conn.close()
except Exception as exc:
logger.warning("Could not persist filer_id_499 on %s: %s", entity_id, exc)
def _schedule_first_499a(self, order_number: str, entity: dict) -> None:
"""Write the next April 1 deadline into ERPNext Compliance Calendar."""
try:
from scripts.workers.erpnext_client import ERPNextClient
year = datetime.utcnow().year + (
0 if datetime.utcnow().month < 4 else 1
)
due = date(year, 4, 1).strftime("%Y-%m-%d")
ERPNextClient().create_resource(
"Compliance Calendar",
{
"entity_name": entity.get("legal_name", ""),
"order_reference": order_number,
"compliance_type": "FCC Form 499-A",
"description": (
f"First annual 499-A filing for "
f"{entity.get('legal_name', '')} (Filer ID pending). "
f"Due April 1 following the reporting year."
),
"due_date": due,
"recurring": 1,
"recurrence_period":"Yearly",
"status": "Upcoming",
},
)
logger.info(
"Form499InitialHandler: scheduled first 499-A for %s on %s",
entity.get("legal_name", ""), due,
)
except Exception as exc:
logger.warning("Could not schedule first 499-A calendar entry: %s", exc)
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}",
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
"""Employee Handbook / Policy Review handler (LLM-based).
Reviews existing employee handbooks for compliance with federal and state
employment laws, or develops new policies from scratch.
This handler is used for both "handbook-review" and "policy-development"
service slugs (different templates, same processing logic).
"""
from __future__ import annotations
import os
from .base_handler import BaseServiceHandler
SERVICE_SYSTEM_PROMPT = """You are a compliance analyst at Performance West Inc.
generating an Employee Handbook & Policy Compliance Review report.
RULES:
- Write in professional, clear business English
- Reference specific federal laws (FMLA, ADA, Title VII, FLSA, OSHA, NLRA, etc.)
- Reference applicable state and local laws based on the client's locations
- Never provide legal advice use "we recommend" not "you must"
- For each policy area: current state, applicable law, compliance gaps, recommended language
- Rate findings: Compliant, Needs Update, Missing, Non-Compliant
- Consider at-will employment disclaimers, anti-harassment, leave policies, etc.
"""
SECTIONS = [
{
"name": "executive_summary",
"prompt": (
"Write a 200-word executive summary of the handbook/policy review. "
"Include scope, overall compliance rating, number of policy areas "
"reviewed, and highest-priority gaps."
),
},
{
"name": "employment_relationship",
"prompt": (
"Review policies related to the employment relationship: at-will "
"disclaimers, equal employment opportunity, ADA accommodation, "
"anti-harassment/anti-discrimination, complaint procedures, and "
"immigration compliance (I-9). For each: status, applicable law, gaps."
),
},
{
"name": "compensation_and_hours",
"prompt": (
"Review wage and hour policies: pay periods, overtime, meal and "
"rest breaks, timekeeping, classifications, pay equity, and expense "
"reimbursement. Reference FLSA and applicable state wage laws."
),
},
{
"name": "leave_and_benefits",
"prompt": (
"Review leave policies: FMLA, state family/medical leave, sick leave, "
"PTO, jury duty, military leave (USERRA), voting leave, bereavement. "
"Assess benefit-related policies for ERISA and ACA compliance."
),
},
{
"name": "workplace_safety",
"prompt": (
"Review workplace safety and health policies: OSHA general duty, "
"hazard communication, workplace violence prevention, drug-free "
"workplace, workers' compensation, and remote work safety."
),
},
{
"name": "technology_and_privacy",
"prompt": (
"Review technology and privacy policies: electronic communications, "
"social media, BYOD, monitoring/surveillance disclosures, data "
"protection, and employee privacy rights under applicable state laws."
),
},
{
"name": "remediation_plan",
"prompt": (
"Provide a prioritized action plan for updating the handbook. "
"For each item: policy area, current status, required change, "
"applicable law, priority level, and recommended implementation timeline."
),
},
]
class HandbookReviewHandler(BaseServiceHandler):
SERVICE_SLUG = "handbook-review"
SERVICE_NAME = "Employee Handbook & Policy Review"
TEMPLATE_NAME = "handbook_review_template.docx"
REQUIRES_LLM = True
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
context = self._extract_order_context(order_data)
# Determine template based on item code (policy-development uses alt template)
items = order_data.get("items", [])
item_code = items[0].get("item_code", "") if items else ""
if item_code == "policy-development":
template_name = "policy_development_template.docx"
else:
template_name = self.TEMPLATE_NAME
template_path = self._get_template_path(template_name)
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": __import__("datetime").datetime.now().strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
"company_size": order_data.get("custom_company_size", "N/A"),
"industry": order_data.get("custom_industry", "N/A"),
"state": order_data.get("custom_state", "N/A"),
}
self._fill_template(template_path, variables, docx_path)
sections = await self._generate_sections(
SERVICE_SYSTEM_PROMPT, SECTIONS, context
)
self._add_sections_to_doc(docx_path, sections)
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,133 @@
"""New Carrier Onboarding bundle.
One order, five sub-handlers executed in order: CORES/FRN Form 499
Initial RMD CPNI CALEA SSI. Each sub-handler honors the global
auto-filing toggle independently; bundle halts only on hard-gating
failures (missing FRN blocks 499 Initial and downstream RMD / CPNI).
"""
from __future__ import annotations
import copy
import logging
from .base_handler import BaseServiceHandler
from .cores_frn_registration import CORESFRNRegistrationHandler
from .dc_agent import DCAgentHandler
from .form_499_initial import Form499InitialHandler
from .rmd_filing import RMDFilingHandler
from .cpni_certification import CPNIFilingHandler
from .calea_ssi import CALEASSIHandler
logger = logging.getLogger(__name__)
class NewCarrierBundleHandler(BaseServiceHandler):
SERVICE_SLUG = "new-carrier-bundle"
SERVICE_NAME = "New Carrier Onboarding Bundle"
REQUIRES_LLM = False
SUB_HANDLERS = (
("cores-frn-registration", CORESFRNRegistrationHandler, True), # hard gate: blocks rest w/o FRN
("dc-agent", DCAgentHandler, False), # soft fail: 499 can default to NWRA
("fcc-499-initial", Form499InitialHandler, True), # hard gate: 499-A needs Filer ID
("rmd-filing", RMDFilingHandler, False), # soft fail
("cpni-certification", CPNIFilingHandler, False),
("calea-ssi", CALEASSIHandler, False),
)
async def process(self, order_data: dict) -> list[str]:
generated: list[str] = []
order_number = order_data.get("name", "")
entity = dict(order_data.get("entity", {}))
for slug, cls, is_hard_gate in self.SUB_HANDLERS:
logger.info("NewCarrierBundle: dispatching %s for %s", slug, order_number)
# Deep copy so each sub-handler gets its own intake_data dict —
# mutations in one sub-handler must not leak to downstream siblings.
sub_order = copy.deepcopy(order_data)
sub_order["entity"] = dict(entity)
sub_order["service_slug"] = slug
sub = cls()
try:
files = await sub.process(sub_order)
if files:
generated.extend(files)
except Exception as exc:
logger.exception(
"NewCarrierBundle: %s raised for %s: %s",
cls.__name__, order_number, exc,
)
self._record_sub_failure(order_number, cls.__name__, str(exc))
if is_hard_gate:
logger.warning(
"NewCarrierBundle: halting remaining sub-handlers "
"(hard gate %s failed)", slug,
)
return generated
# Refresh entity snapshot — CORES writes FRN, 499 Initial writes
# filer_id_499; downstream handlers need the updated values.
entity = self._reload_entity(entity.get("id")) or entity
# Hard-gate sanity: after CORES, we should have an FRN.
if slug == "cores-frn-registration" and not entity.get("frn"):
self._record_sub_failure(
order_number, "CORES",
"No FRN assigned — remaining bundle steps require it.",
)
if is_hard_gate:
return generated
if slug == "fcc-499-initial" and not entity.get("filer_id_499"):
self._record_sub_failure(
order_number, "Form499Initial",
"No Filer ID assigned — RMD and CPNI need it on the cert letters.",
)
return generated
# ------------------------------------------------------------------
def _reload_entity(self, entity_id) -> dict | None:
if not entity_id:
return None
try:
import os
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT * FROM telecom_entities WHERE id=%s",
(entity_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
except Exception as exc:
logger.warning("NewCarrierBundle: could not reload entity %s: %s", entity_id, exc)
return None
def _record_sub_failure(self, order_number: str, sub: str, detail: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n"
f"Sub-handler {sub} failed or produced incomplete state: "
f"{detail}. Other sub-handlers either continued "
f"(soft-fail) or halted (hard gate). Review and "
f"re-dispatch the failing slug once the underlying "
f"issue is fixed."
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create bundle sub-failure ToDo: %s", exc)

View file

@ -0,0 +1,126 @@
"""NECA OCN / Company Code registration handler.
Generates the NECA Company Code Request Form packet for a carrier and
creates an ERPNext admin ToDo for the Accounting Advisor to mail/fax/email
the packet to NECA along with the required fee. Once NECA assigns the
OCN (10 business days standard / 5 expedited), the admin records it on
the telecom_entity subsequent compliance checkups and 499-A filings
read it from there.
Because NECA does not offer an API or self-service submission, this
handler is inherently human-in-the-loop there is no Playwright flow
for this service. The auto-filing toggle is therefore not gated: the
handler only produces the packet + ToDo, never submits directly.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
class OCNRegistrationHandler(BaseServiceHandler):
SERVICE_SLUG = "ocn-registration"
SERVICE_NAME = "NECA OCN / Company Code Registration"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
intake = order_data.get("intake_data") or {}
generated: list[str] = []
# Service category defaults to IPES (VoIP) unless intake specifies.
category = (intake.get("service_category") or "IPES").upper()
expedited = bool(intake.get("expedited", False))
operating_states = intake.get("operating_states") or []
# Derive contact info from the entity; default requestor to PW.
from scripts.document_gen.templates.ocn_request_form_generator import (
generate_ocn_request_packet,
)
date_str = datetime.now().strftime("%Y%m%d")
packet_docx = os.path.join(
work_dir,
f"ocn_request_packet_{order_number}_{date_str}.docx",
)
result = generate_ocn_request_packet(
entity_name=entity.get("legal_name", ""),
legal_entity_full=entity.get("legal_name", ""),
company_contact_name=entity.get("contact_name", ""),
company_contact_voice=entity.get("contact_phone", ""),
company_contact_email=entity.get("contact_email", ""),
company_contact_address=", ".join(filter(None, [
entity.get("address_street", ""),
entity.get("address_city", ""),
f"{entity.get('address_state', '')} {entity.get('address_zip', '')}".strip(),
])),
service_category=category,
operating_states=operating_states,
expedited=expedited,
output_path=packet_docx,
)
if result:
generated.append(result)
try:
generated.append(self._convert_to_pdf(result))
except Exception as exc:
logger.warning("OCN packet PDF conversion failed: %s", exc)
self._create_admin_todo(
order_number=order_number,
entity=entity,
category=category,
expedited=expedited,
)
return generated
def _create_admin_todo(
self,
*,
order_number: str,
entity: dict,
category: str,
expedited: bool,
) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
fee = "$675 (5 business days)" if expedited else "$550 (10 business days)"
description = (
f"[{self.SERVICE_SLUG}] {order_number}\n\n"
f"Submit NECA Company Code request on behalf of this client.\n\n"
f"Entity legal name: {entity.get('legal_name', '')}\n"
f"DBA: {entity.get('dba_name', '')}\n"
f"FRN: {entity.get('frn', 'N/A')}\n"
f"Service category: {category}\n"
f"Processing: {fee}\n\n"
f"Steps:\n"
f" 1. Gather supporting docs (Articles of Incorporation for all "
f"categories; interconnection agreements + customer invoices for "
f"IPES; state PUC cert for CLEC/ULEC).\n"
f" 2. Send the generated packet (in MinIO) + supporting docs + "
f"fee to NECA via fax 973-993-1063 or ccfees@neca.org.\n"
f" 3. Pay NECA fee via Relay virtual debit card (SID-0002).\n"
f" 4. On OCN assignment: update telecom_entities.ocn and "
f"telecom_entities.ocn_assigned_at on this carrier.\n"
f" 5. Email client with the assigned OCN."
)
ERPNextClient().create_resource(
"ToDo",
{
"description": description,
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create OCN admin ToDo: %s", exc)

View file

@ -0,0 +1,260 @@
"""
Porkbun .ca domain registration service.
Production client for registering .ca domains via the Porkbun REST API.
Called by the CRTC pipeline to register the client's .ca domain.
WHOIS Contact Setup (Porkbun account-level set once in dashboard):
Technical contact: Performance West Inc. (filings@performancewest.net)
Billing contact: Performance West Inc. (admin@performancewest.net)
Admin/Registrant: Set per-domain to the customer's order email
NOTE: Porkbun v3 API does not support per-domain contact customization.
All domains inherit the account-level default contacts. The account
should be configured in the Porkbun dashboard with PW as tech/billing
and a generic admin contact. Post-registration, the customer email
is set via the CIRA .ca registrant update process (manual for now).
WHOIS Privacy:
Porkbun includes free WHOIS privacy for most TLDs. For .ca domains,
CIRA (the .ca registry) requires the registrant organization name to
be publicly visible regardless of privacy settings. Individual contact
details (name, address, phone, email) can be hidden.
Environment variables:
PORKBUN_API_KEY pk1_...
PORKBUN_SECRET_KEY sk1_...
Usage:
from scripts.workers.services.porkbun import register_ca_domain
result = register_ca_domain("testcompany.ca", order_number="CA-2026-XXXXX")
"""
from __future__ import annotations
import logging
import os
import re
import socket
from typing import Optional
import requests
LOG = logging.getLogger("workers.porkbun")
API_BASE = "https://api.porkbun.com/api/json/v3"
_api_key = os.environ.get("PORKBUN_API_KEY", "")
_secret_key = os.environ.get("PORKBUN_SECRET_KEY", "")
def _auth(**extra) -> dict:
return {"apikey": _api_key, "secretapikey": _secret_key, **extra}
def _post(path: str, **extra) -> dict:
r = requests.post(f"{API_BASE}/{path}", json=_auth(**extra), timeout=30)
r.raise_for_status()
return r.json()
def ping() -> bool:
"""Verify API credentials."""
try:
data = _post("ping")
ok = data.get("status") == "SUCCESS"
LOG.info("Porkbun ping: %s (IP: %s)", "OK" if ok else "FAIL", data.get("yourIp"))
return ok
except Exception as e:
LOG.error("Porkbun ping failed: %s", e)
return False
def check_availability_whois(domain: str) -> bool:
"""Check .ca domain availability via CIRA WHOIS (Porkbun API doesn't support this)."""
try:
s = socket.create_connection(("whois.cira.ca", 43), timeout=10)
s.sendall(f"{domain}\r\n".encode())
response = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
response += chunk
s.close()
text = response.decode("utf-8", errors="ignore")
available = "Not found" in text
LOG.info("WHOIS %s: %s", domain, "AVAILABLE" if available else "TAKEN")
return available
except Exception as e:
LOG.error("WHOIS check failed for %s: %s", domain, e)
return False
def register_domain(
domain: str,
nameservers: Optional[list[str]] = None,
years: int = 1,
) -> dict:
"""
Register a .ca domain via Porkbun.
Args:
domain: Full domain name (e.g. "mycompany.ca")
nameservers: Custom NS records. Defaults to HestiaCP NS.
years: Registration period (default 1 year)
Returns:
{"success": bool, "domain": str, "error": str}
"""
# Default to HestiaCP nameservers
if not nameservers:
nameservers = [
"ns0.cp.carrierone.com",
"ns1.he.net",
"ns2.he.net",
"ns3.he.net",
"ns4.he.net",
"ns5.he.net",
]
try:
body = _auth(domain=domain, years=years)
if nameservers:
body["ns"] = nameservers
r = requests.post(
f"{API_BASE}/domain/register/{domain}",
json=body,
timeout=60,
)
data = r.json()
if data.get("status") == "SUCCESS":
LOG.info("Porkbun: registered %s for %d year(s)", domain, years)
return {"success": True, "domain": domain}
else:
msg = data.get("message", str(data))
LOG.error("Porkbun registration failed for %s: %s", domain, msg)
return {"success": False, "domain": domain, "error": msg}
except Exception as e:
LOG.error("Porkbun registration error for %s: %s", domain, e)
return {"success": False, "domain": domain, "error": str(e)}
def set_whois_privacy(domain: str, enabled: bool = True) -> dict:
"""
Enable or disable WHOIS privacy for a domain.
For .ca domains: CIRA requires the registrant organization name to remain
publicly visible. WHOIS privacy only hides individual contact details
(name, address, phone, email).
Porkbun v3 API doesn't have a dedicated WHOIS privacy endpoint, but
domains registered under our account get free WHOIS privacy by default.
This function is a placeholder for when the API supports it, or for
toggling via the Porkbun dashboard.
"""
LOG.info("WHOIS privacy for %s: %s", domain, "enabled" if enabled else "disabled")
# Porkbun includes free WHOIS privacy by default.
# No API endpoint to toggle it — must be done in Porkbun dashboard if needed.
# For .ca: CIRA publishes org name regardless.
return {"success": True, "domain": domain, "privacy": enabled}
def register_ca_domain(
domain: str,
order_number: str = "",
skip_whois: bool = False,
domain_privacy: bool = True,
customer_email: str = "",
) -> dict:
"""
Full .ca domain registration workflow:
1. WHOIS availability check
2. Porkbun registration with HestiaCP nameservers
3. Set WHOIS privacy preference
4. Return result
WHOIS contact roles:
Technical + Billing: Performance West (set at Porkbun account level)
Admin/Registrant: Customer email (set post-registration via CIRA if needed)
Args:
domain: The .ca domain to register
order_number: For logging context
skip_whois: Skip availability check (if already verified)
domain_privacy: Whether to enable WHOIS privacy (default True)
customer_email: Customer's email for CIRA registrant contact
Returns:
{"success": bool, "domain": str, "error": str}
"""
LOG.info("[%s] Registering .ca domain: %s (privacy=%s, customer=%s)",
order_number, domain, domain_privacy, customer_email or "account default")
# Validate it's a .ca domain
if not domain.endswith(".ca"):
return {"success": False, "domain": domain, "error": "Not a .ca domain"}
# Clean the domain
domain = domain.lower().strip()
# Step 1: Check availability
if not skip_whois:
if not check_availability_whois(domain):
return {"success": False, "domain": domain, "error": "Domain is not available"}
# Step 2: Register
result = register_domain(domain)
if result["success"]:
# Step 3: Set WHOIS privacy preference
set_whois_privacy(domain, enabled=domain_privacy)
# Step 4: Log customer email for CIRA registrant update
# NOTE: Porkbun API doesn't support per-domain contact changes.
# The registrant contact defaults to the Porkbun account owner (PW).
# For .ca domains, the customer should be the admin contact — this
# requires a manual CIRA registrant transfer or a support ticket to
# Porkbun to update the admin contact to the customer's email.
if customer_email:
LOG.info(
"[%s] Customer email for CIRA admin contact: %s "
"(manual Porkbun support ticket needed to set per-domain admin)",
order_number, customer_email,
)
return result
def generate_domain_name(company_name: str, bc_number: str = "") -> str:
"""
Generate a .ca domain name from a company name.
For numbered companies: uses a slugified version of the trade name or
falls back to "pw-{bc_number}.ca"
For named companies: slugifies the company name, removes legal endings.
"""
if not company_name or company_name.lower().startswith("numbered"):
if bc_number:
return f"pw-{bc_number.lower()}.ca"
return ""
# Remove legal endings
name = re.sub(
r"\s+(ltd\.?|inc\.?|corp\.?|llc\.?|co\.?|limited|incorporated|corporation)\s*$",
"",
company_name,
flags=re.IGNORECASE,
).strip()
# Slugify
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# Truncate to 63 chars (DNS label limit)
if len(slug) > 59: # leave room for ".ca"
slug = slug[:59].rstrip("-")
return f"{slug}.ca" if slug else ""

View file

@ -0,0 +1,66 @@
"""Privacy Policy Generator handler (template-based).
Generates a comprehensive privacy policy document by filling a DOCX template
with customer-specific information. Does not require LLM generation uses
pre-written policy sections with variable substitution.
"""
from __future__ import annotations
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
class PrivacyPolicyHandler(BaseServiceHandler):
SERVICE_SLUG = "privacy-policy"
SERVICE_NAME = "Privacy Policy Generation"
TEMPLATE_NAME = "privacy_policy_template.docx"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
template_path = self._get_template_path()
docx_filename = self._output_filename(order_number, "docx")
docx_path = os.path.join(work_dir, docx_filename)
# Extract intake data for template variables
now = datetime.now()
variables = {
"order_number": order_number,
"customer_name": order_data.get("customer_name", ""),
"date": now.strftime("%B %d, %Y"),
"effective_date": now.strftime("%B %d, %Y"),
"service_name": self.SERVICE_NAME,
# Company details
"company_name": order_data.get("custom_company_legal_name", order_data.get("customer_name", "")),
"company_website": order_data.get("custom_company_website", "[WEBSITE]"),
"company_address": order_data.get("custom_company_address", "[ADDRESS]"),
"company_email": order_data.get("custom_contact_email", "[EMAIL]"),
"company_phone": order_data.get("custom_contact_phone", "[PHONE]"),
# Privacy officer
"privacy_officer_name": order_data.get("custom_privacy_officer", "[PRIVACY OFFICER]"),
"privacy_officer_email": order_data.get("custom_privacy_officer_email", "[PRIVACY OFFICER EMAIL]"),
# Data practices
"data_collected": order_data.get("custom_data_types_collected", "name, email address, phone number, and other information you provide"),
"data_purposes": order_data.get("custom_data_purposes", "providing our services, communicating with you, and improving our offerings"),
"data_sharing": order_data.get("custom_data_sharing_parties", "service providers who assist in our operations"),
"data_retention": order_data.get("custom_data_retention_period", "as long as necessary to fulfill the purposes described in this policy"),
# Jurisdiction
"state": order_data.get("custom_state", "California"),
"applicable_laws": order_data.get("custom_applicable_privacy_laws", "CCPA/CPRA"),
# Cookies
"uses_cookies": order_data.get("custom_uses_cookies", "Yes"),
"uses_analytics": order_data.get("custom_uses_analytics", "Yes"),
"analytics_provider": order_data.get("custom_analytics_provider", "Google Analytics"),
}
self._fill_template(template_path, variables, docx_path)
# Convert to PDF
pdf_path = self._convert_to_pdf(docx_path)
return [docx_path, pdf_path]

View file

@ -0,0 +1,741 @@
"""
Renewal handler service worker.
Manages annual renewals for Canada CRTC (and other) entities:
- Polls ERPNext Compliance Calendar for upcoming due dates
- Sends email reminders at 30, 14, 7, and 3 days before due date
- On renewal date: creates ERPNext Sales Invoice + sends Stripe Checkout link to client
- Dunning sequence if unpaid after 7, 14 days
- Admin alert after 14 days unpaid
This worker runs as a scheduled task (e.g., cron or systemd timer) rather than
being triggered by a Sales Order like other service handlers.
Environment variables:
ERPNEXT_URL, ERPNEXT_API_KEY, ERPNEXT_API_SECRET
API_URL Express API URL (default: http://api:3001)
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
"""
from __future__ import annotations
import logging
import os
import smtplib
from datetime import datetime, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
LOG = logging.getLogger("workers.renewal_handler")
# Stripe secret is no longer used directly in this module (payment is via Express API
# checkout flow + Stripe webhook). Kept here in case it's needed for future
# off-session charging once saved payment methods are implemented.
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "orders@performancewest.net")
ADMIN_EMAIL = "ops@performancewest.net"
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
# Reminder schedule: days before due date
REMINDER_DAYS = [30, 14, 7, 3]
# Dunning schedule: days after failed payment
DUNNING_DAYS = [0, 7, 14]
class RenewalHandler:
"""Handles annual compliance renewals for all managed entities.
Designed to run on a daily schedule (e.g., cron job or systemd timer).
Each run:
1. Queries ERPNext Compliance Calendar for entries due within window
2. Sends appropriate reminders or processes renewals
3. Handles payment failures with dunning sequence
"""
def __init__(self) -> None:
self._erp = None
self._stripe = None
# ------------------------------------------------------------------ #
# ERPNext client
# ------------------------------------------------------------------ #
@property
def erp(self):
if self._erp is None:
from scripts.workers.erpnext_client import ERPNextClient
self._erp = ERPNextClient()
return self._erp
# ------------------------------------------------------------------ #
# Stripe client
# ------------------------------------------------------------------ #
@property
def stripe(self):
if self._stripe is None:
import stripe
stripe.api_key = STRIPE_SECRET_KEY
self._stripe = stripe
return self._stripe
# ------------------------------------------------------------------ #
# Main entry point — run daily
# ------------------------------------------------------------------ #
def run(self) -> dict[str, int]:
"""Execute a single renewal check cycle.
Returns:
Summary dict with counts: reminders_sent, renewals_processed,
payments_failed, admin_alerts.
"""
LOG.info("=== Renewal handler cycle START ===")
today = datetime.utcnow().date()
summary = {
"reminders_sent": 0,
"renewals_processed": 0,
"payments_failed": 0,
"admin_alerts": 0,
}
# -------------------------------------------------------------- #
# Phase 1: Send upcoming reminders
# -------------------------------------------------------------- #
for days_before in REMINDER_DAYS:
target_date = (today + timedelta(days=days_before)).strftime("%Y-%m-%d")
entries = self._get_compliance_entries(
due_date=target_date,
statuses=["Upcoming", "Reminder Sent"],
)
for entry in entries:
self._send_reminder(entry, days_before)
self._update_entry_status(entry["name"], "Reminder Sent")
summary["reminders_sent"] += 1
# -------------------------------------------------------------- #
# Phase 2: Process renewals due today
# -------------------------------------------------------------- #
due_today = self._get_compliance_entries(
due_date=today.strftime("%Y-%m-%d"),
statuses=["Upcoming", "Reminder Sent", "Due"],
)
for entry in due_today:
self._update_entry_status(entry["name"], "Processing")
success = self._process_renewal(entry)
if success:
self._update_entry_status(entry["name"], "Completed")
self._create_next_recurrence(entry)
summary["renewals_processed"] += 1
else:
self._update_entry_status(entry["name"], "Payment Failed")
self._send_dunning_email(entry, days_overdue=0)
summary["payments_failed"] += 1
# -------------------------------------------------------------- #
# Phase 3: Dunning for previously failed payments
# -------------------------------------------------------------- #
for days_overdue in DUNNING_DAYS[1:]: # Skip 0 (handled above)
overdue_date = (today - timedelta(days=days_overdue)).strftime("%Y-%m-%d")
failed_entries = self._get_compliance_entries(
due_date=overdue_date,
statuses=["Payment Failed", "Overdue"],
)
for entry in failed_entries:
if days_overdue >= 14:
# Final dunning — admin alert
self._send_admin_alert(entry, days_overdue)
self._update_entry_status(entry["name"], "Admin Alert")
summary["admin_alerts"] += 1
else:
# Retry payment + send dunning email
success = self._retry_payment(entry)
if success:
self._process_renewal_actions(entry)
self._update_entry_status(entry["name"], "Completed")
self._create_next_recurrence(entry)
summary["renewals_processed"] += 1
else:
self._send_dunning_email(entry, days_overdue)
self._update_entry_status(entry["name"], "Overdue")
summary["payments_failed"] += 1
LOG.info(
"=== Renewal handler cycle COMPLETE: %d reminders, %d renewals, %d failed, %d alerts ===",
summary["reminders_sent"],
summary["renewals_processed"],
summary["payments_failed"],
summary["admin_alerts"],
)
return summary
# ------------------------------------------------------------------ #
# ERPNext Compliance Calendar queries
# ------------------------------------------------------------------ #
def _get_compliance_entries(
self,
due_date: str,
statuses: list[str],
) -> list[dict[str, Any]]:
"""Fetch Compliance Calendar entries from ERPNext for a given date and status."""
try:
entries = self.erp.get_resource(
"Compliance Calendar",
filters={
"due_date": due_date,
"status": ["in", statuses],
},
fields=[
"name", "entity_name", "order_reference", "compliance_type",
"description", "due_date", "amount_cad", "recurring",
"recurrence_period", "status",
],
limit=100,
)
if isinstance(entries, dict):
entries = [entries]
return entries
except Exception as exc:
LOG.error("Failed to query Compliance Calendar for %s: %s", due_date, exc)
return []
def _update_entry_status(self, entry_name: str, status: str) -> None:
"""Update a Compliance Calendar entry's status in ERPNext."""
try:
self.erp.update_resource("Compliance Calendar", entry_name, {
"status": status,
})
LOG.info("Compliance entry %s%s", entry_name, status)
except Exception as exc:
LOG.error("Failed to update compliance entry %s to %s: %s", entry_name, status, exc)
def _create_next_recurrence(self, entry: dict) -> None:
"""Create the next year's compliance entry if recurring."""
if not entry.get("recurring"):
return
try:
current_due = datetime.strptime(entry["due_date"], "%Y-%m-%d")
period = entry.get("recurrence_period", "Yearly")
if period == "Yearly":
next_due = current_due + timedelta(days=365)
elif period == "Monthly":
next_due = current_due + timedelta(days=30)
elif period == "Quarterly":
next_due = current_due + timedelta(days=91)
else:
next_due = current_due + timedelta(days=365)
new_entry = {
"doctype": "Compliance Calendar",
"entity_name": entry["entity_name"],
"order_reference": entry["order_reference"],
"compliance_type": entry["compliance_type"],
"description": entry["description"],
"due_date": next_due.strftime("%Y-%m-%d"),
"amount_cad": entry.get("amount_cad", 0),
"recurring": 1,
"recurrence_period": period,
"status": "Upcoming",
}
self.erp.create_resource("Compliance Calendar", new_entry)
LOG.info(
"Created next recurrence for %s (%s): due %s",
entry["entity_name"],
entry["compliance_type"],
next_due.strftime("%Y-%m-%d"),
)
except Exception as exc:
LOG.error("Failed to create next recurrence for %s: %s", entry["name"], exc)
# ------------------------------------------------------------------ #
# Renewal processing
# ------------------------------------------------------------------ #
def _process_renewal(self, entry: dict) -> bool:
"""Process a renewal: charge payment, then execute renewal actions.
Returns True if payment succeeded.
"""
compliance_type = entry.get("compliance_type", "")
entity_name = entry.get("entity_name", "")
amount_cad = entry.get("amount_cad", 0)
LOG.info("Processing renewal: %s for %s (C$%.2f)", compliance_type, entity_name, amount_cad)
# Step 1: Create payment invoice + send link (async — customer pays via Stripe)
if amount_cad > 0:
payment_success = self._charge_payment(entry)
if not payment_success:
return False
# Step 2: Execute type-specific renewal actions
self._process_renewal_actions(entry)
return True
def _process_renewal_actions(self, entry: dict) -> None:
"""Execute the actual renewal actions (mailbox, annual report, CRTC check).
Dispatches based on compliance_type.
"""
compliance_type = entry.get("compliance_type", "")
entity_name = entry.get("entity_name", "")
if compliance_type == "Mailbox Renewal":
self._renew_mailbox(entry)
elif compliance_type == "BC Annual Report":
self._file_annual_report(entry)
elif compliance_type == "CRTC Compliance Check":
self._crtc_compliance_check(entry)
else:
LOG.warning("Unknown compliance type: %s for %s", compliance_type, entity_name)
def _renew_mailbox(self, entry: dict) -> bool:
"""Renew Anytime Mailbox via Playwright automation.
Logs into the Anytime Mailbox dashboard and processes the renewal.
Stub requires portal selectors.
"""
entity_name = entry.get("entity_name", "")
LOG.info("Renewing Anytime Mailbox for %s", entity_name)
try:
import asyncio
from scripts.formation.states.bc.adapter import BCPortal
from scripts.formation.base import FormationOrder, EntityType
portal = BCPortal()
# Build minimal order for mailbox renewal
order = FormationOrder(
order_id=entry.get("order_reference", ""),
state_code="BC",
entity_type=EntityType.CORPORATION,
entity_name=entity_name,
)
# TODO: Implement actual mailbox renewal automation
# For now this is a stub — Anytime Mailbox may auto-renew
# if payment method is on file. If not, we need Playwright
# automation to log in and process renewal.
LOG.warning("Mailbox renewal automation not yet implemented — may auto-renew")
return True
except Exception as exc:
LOG.error("Mailbox renewal failed for %s: %s", entity_name, exc)
return False
def _file_annual_report(self, entry: dict) -> bool:
"""File BC Annual Report via Corporate Online (Playwright).
BC Annual Reports confirm registered office address and director info.
Cost: C$42. Must be filed within 2 months of anniversary.
Stub requires Corporate Online selectors.
"""
entity_name = entry.get("entity_name", "")
LOG.info("Filing BC Annual Report for %s", entity_name)
try:
import asyncio
from scripts.formation.states.bc.adapter import BCPortal
from scripts.formation.states.bc.config import CONFIG as BC_CONFIG
portal = BCPortal()
# TODO: Implement Playwright automation for BC Annual Report:
# 1. Login to Corporate Online
# 2. Navigate to Annual Report filing
# 3. Confirm registered office address
# 4. Confirm director information
# 5. Pay C$42 via Relay card
# 6. Capture confirmation number
#
# Selectors in BC_CONFIG["selectors"]:
# ar_filing_year, ar_confirm_address, ar_submit
LOG.warning("BC Annual Report automation not yet implemented — requires portal selectors")
return True
except Exception as exc:
LOG.error("BC Annual Report filing failed for %s: %s", entity_name, exc)
return False
def _crtc_compliance_check(self, entry: dict) -> bool:
"""Perform annual CRTC compliance check.
Verifies that the CRTC registration is still current and checks
for any regulatory changes that may affect the entity.
"""
entity_name = entry.get("entity_name", "")
LOG.info("Performing CRTC compliance check for %s", entity_name)
# CRTC compliance check is currently a manual process:
# 1. Verify entity is still listed in CRTC registry
# 2. Check for new CRTC regulations affecting the entity
# 3. Verify contact information is current
#
# For now, create a task for manual review.
try:
self.erp.create_resource("Issue", {
"subject": f"CRTC Annual Compliance Check: {entity_name}",
"description": (
f"Annual CRTC compliance check due for **{entity_name}**.\n\n"
f"Manual steps:\n"
f"1. Verify entity is still listed in CRTC registry\n"
f"2. Check for new CRTC regulations\n"
f"3. Verify contact information is current\n"
f"4. File any required updates\n\n"
f"Order reference: {entry.get('order_reference', 'N/A')}"
),
"priority": "Medium",
"issue_type": "Feature Request",
})
LOG.info("Created CRTC compliance check task for %s", entity_name)
return True
except Exception as exc:
LOG.error("Failed to create CRTC compliance check task: %s", exc)
return False
# ------------------------------------------------------------------ #
# Payment — ERPNext Sales Invoice + Stripe Checkout Session
# ------------------------------------------------------------------ #
def _charge_payment(self, entry: dict) -> bool:
"""Create an ERPNext Sales Invoice + Stripe Checkout Session for the renewal.
Sends a payment link to the client. Returns True immediately (payment
is async the client pays via the emailed link). The Stripe webhook
will confirm payment and trigger renewal actions.
Falls back to creating an admin ToDo if ERPNext is unavailable.
"""
entity_name = entry.get("entity_name", "")
amount_cad = entry.get("amount_cad", 0)
order_ref = entry.get("order_reference", "")
compliance_type = entry.get("compliance_type", "Annual Renewal")
if amount_cad <= 0:
LOG.info("No charge needed for %s (%s)", entity_name, compliance_type)
return True
LOG.info("Creating renewal invoice C$%.2f for %s (%s)", amount_cad, entity_name, compliance_type)
# Look up customer info from the original Sales Order
client_email = ""
erpnext_customer = ""
try:
order_data = self.erp.get_resource("Sales Order", order_ref)
if isinstance(order_data, list):
order_data = order_data[0] if order_data else {}
client_email = order_data.get("customer_email", "") or order_data.get("custom_customer_email", "")
erpnext_customer = order_data.get("customer", "")
except Exception as exc:
LOG.warning("Could not fetch order data for %s: %s — using admin fallback", order_ref, exc)
if not erpnext_customer:
# Fall back to admin ToDo
LOG.warning("No ERPNext customer found for %s — creating admin task", order_ref)
try:
self.erp.create_resource("ToDo", {
"description": (
f"MANUAL RENEWAL REQUIRED\n"
f" Entity: {entity_name}\n"
f" Order: {order_ref}\n"
f" Type: {compliance_type}\n"
f" Amount: C${amount_cad:.2f}\n\n"
f"Create invoice and send payment link manually."
),
"assigned_by": "Administrator",
})
except Exception as td_err:
LOG.error("Failed to create admin ToDo for %s: %s", order_ref, td_err)
return True # Non-blocking — admin handles it
# Create ERPNext Sales Invoice
try:
invoice = self.erp.create_resource("Sales Invoice", {
"customer": erpnext_customer,
"due_date": (datetime.utcnow() + timedelta(days=14)).strftime("%Y-%m-%d"),
"custom_external_order_id": f"{order_ref}-renewal-{datetime.utcnow().strftime('%Y%m')}",
"custom_order_type": "renewal",
"items": [{
"item_code": compliance_type,
"qty": 1,
"rate": amount_cad,
"description": f"Annual renewal — {entity_name}",
}],
})
invoice_name = invoice.get("name", "")
self.erp.call_method("frappe.client.submit", {
"doc": {"doctype": "Sales Invoice", "name": invoice_name}
})
# Create Stripe Payment Request via Express API
import urllib.request, json as _json
api_url = os.getenv("API_URL", "http://api:3001")
payload = _json.dumps({
"order_id": f"{order_ref}-renewal",
"order_type": "compliance",
"payment_method": "card",
}).encode()
req = urllib.request.Request(
f"{api_url}/api/v1/checkout/create-session",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
result = _json.loads(resp.read())
checkout_url = result.get("checkout_url", "")
if checkout_url and client_email:
# Send payment link email
self._send_email(
to_email=client_email,
subject=f"Renewal payment required — {entity_name}",
body=(
f"Your annual {compliance_type} renewal for {entity_name} "
f"is due. Amount: C${amount_cad:.2f}.\n\n"
f"Pay here: {checkout_url}\n\n"
f"This link expires in 7 days. Reply to this email if you "
f"have questions.\n\nPerformance West Inc."
),
)
LOG.info("Renewal payment link sent to %s for %s", client_email, entity_name)
self.erp.update_resource("Compliance Calendar", entry["name"], {
"custom_invoice_name": invoice_name,
"custom_payment_date": datetime.utcnow().strftime("%Y-%m-%d"),
})
return True # Payment link sent; actual confirmation via Stripe webhook
except Exception as exc:
LOG.error("Renewal payment setup failed for %s: %s", entity_name, exc)
return False
def _retry_payment(self, entry: dict) -> bool:
"""Retry a failed payment by re-sending the payment link."""
LOG.info("Retrying payment for %s", entry.get("entity_name", ""))
return self._charge_payment(entry)
# ------------------------------------------------------------------ #
# Email: reminders & dunning
# ------------------------------------------------------------------ #
def _send_reminder(self, entry: dict, days_before: int) -> None:
"""Send a renewal reminder email to the client."""
entity_name = entry.get("entity_name", "")
compliance_type = entry.get("compliance_type", "Renewal")
due_date = entry.get("due_date", "")
amount_cad = entry.get("amount_cad", 0)
order_ref = entry.get("order_reference", "")
# Look up client email from ERPNext order
client_email = self._get_client_email(order_ref)
if not client_email:
LOG.warning("No client email found for order %s — skipping reminder", order_ref)
return
subject = f"Upcoming {compliance_type}{entity_name} — Due in {days_before} days"
body = (
f"Dear Client,\n\n"
f"This is a reminder that the following compliance obligation "
f"is coming due:\n\n"
f" Entity: {entity_name}\n"
f" Type: {compliance_type}\n"
f" Due Date: {due_date}\n"
)
if amount_cad > 0:
body += f" Amount: C${amount_cad:.2f}\n"
body += (
f"\n"
f"Your payment method on file will be charged automatically on "
f"the due date. If you need to update your payment method, "
f"please contact us before then.\n\n"
f"If you have any questions, please reply to this email.\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
)
self._send_email(to_email=client_email, subject=subject, body=body)
LOG.info("Sent %d-day reminder to %s for %s (%s)", days_before, client_email, entity_name, compliance_type)
def _send_dunning_email(self, entry: dict, days_overdue: int) -> None:
"""Send a payment failure / dunning email to the client."""
entity_name = entry.get("entity_name", "")
compliance_type = entry.get("compliance_type", "Renewal")
amount_cad = entry.get("amount_cad", 0)
order_ref = entry.get("order_reference", "")
client_email = self._get_client_email(order_ref)
if not client_email:
LOG.warning("No client email found for order %s — skipping dunning email", order_ref)
return
if days_overdue == 0:
urgency = "Payment Failed"
deadline_note = "Please update your payment method within 7 days to avoid service interruption."
elif days_overdue <= 7:
urgency = "Payment Past Due"
deadline_note = (
"Your payment is 7 days past due. Please update your payment "
"method immediately to maintain your compliance status."
)
else:
urgency = "URGENT: Payment Overdue"
deadline_note = (
"Your payment is 14 days overdue. Your compliance obligations "
"may lapse if payment is not received. This is our final notice "
"before escalation to an administrator."
)
subject = f"[{urgency}] {compliance_type}{entity_name}"
body = (
f"Dear Client,\n\n"
f"We were unable to process your payment for the following "
f"compliance obligation:\n\n"
f" Entity: {entity_name}\n"
f" Type: {compliance_type}\n"
f" Amount: C${amount_cad:.2f}\n\n"
f"{deadline_note}\n\n"
f"To update your payment method, please reply to this email "
f"or contact us at {ADMIN_EMAIL}.\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
)
self._send_email(to_email=client_email, subject=subject, body=body)
LOG.info(
"Sent dunning email (%d days overdue) to %s for %s",
days_overdue, client_email, entity_name,
)
def _send_admin_alert(self, entry: dict, days_overdue: int) -> None:
"""Send admin alert after dunning sequence is exhausted."""
entity_name = entry.get("entity_name", "")
compliance_type = entry.get("compliance_type", "")
order_ref = entry.get("order_reference", "")
amount_cad = entry.get("amount_cad", 0)
subject = (
f"[ADMIN ALERT] Unpaid Renewal: {entity_name}"
f"{compliance_type}{days_overdue} days overdue"
)
body = (
f"The following compliance renewal is {days_overdue} days overdue "
f"after exhausting the automated dunning sequence:\n\n"
f" Entity: {entity_name}\n"
f" Type: {compliance_type}\n"
f" Amount: C${amount_cad:.2f}\n"
f" Order: {order_ref}\n"
f" Days Overdue: {days_overdue}\n\n"
f"Manual intervention required:\n"
f" 1. Contact client directly\n"
f" 2. Decide whether to process renewal anyway or cancel\n"
f" 3. Update compliance entry status in ERPNext\n"
)
self._send_email(to_email=ADMIN_EMAIL, subject=subject, body=body)
# Also create an ERPNext Issue for tracking
try:
self.erp.create_resource("Issue", {
"subject": subject,
"description": body,
"priority": "High",
"issue_type": "Bug",
})
except Exception as exc:
LOG.error("Failed to create admin alert issue: %s", exc)
LOG.info("Admin alert sent for %s (%s) — %d days overdue", entity_name, compliance_type, days_overdue)
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _get_client_email(self, order_ref: str) -> str:
"""Look up the client's email from the ERPNext order."""
if not order_ref:
return ""
try:
order_data = self.erp.get_resource("Sales Order", order_ref)
if isinstance(order_data, list):
if not order_data:
return ""
order_data = order_data[0]
return (
order_data.get("custom_client_email")
or order_data.get("customer_email")
or ""
)
except Exception as exc:
LOG.error("Failed to look up client email for %s: %s", order_ref, exc)
return ""
@staticmethod
def _send_email(to_email: str, subject: str, body: str) -> None:
"""Send a plain-text email via SMTP."""
if not SMTP_USER or not SMTP_PASSWORD:
LOG.warning("SMTP not configured — skipping email to %s", to_email)
return
msg = MIMEMultipart()
msg["From"] = SMTP_FROM
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.send_message(msg)
LOG.info("Email sent to %s: %s", to_email, subject)
except Exception as exc:
LOG.error("Failed to send email to %s: %s", to_email, exc)
# --------------------------------------------------------------------------- #
# CLI entry point — run one cycle
# --------------------------------------------------------------------------- #
def main() -> None:
"""Run a single renewal check cycle (for cron or manual execution)."""
import logging
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
handler = RenewalHandler()
summary = handler.run()
print(f"Renewal cycle complete: {summary}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,485 @@
"""RMD Registration / Recertification filing handler.
Flow:
1. Generate the RMD certification letter (and Exhibit A if partial STIR/SHAKEN)
using the existing document generators.
2. Convert to PDF.
3. Launch an undetected browser, navigate to the FCC Robocall Mitigation
Database portal (https://apps.fcc.gov/rmd/), log in using the CORES
session previously authorized via the chrome-extension/fcc-access-helper
for this carrier's FRN.
4. Submit the certification (or recertification) form, attaching the
signed DOCX. Capture the confirmation page and store it in MinIO as
``rmd_confirmation_{order}.pdf``.
5. On success: ``UPDATE telecom_entities SET rmd_last_cert_date = NOW(),
rmd_confirmation_number = $conf`` so the next checkup reads green.
Prerequisite: the carrier's FRN must already have filings@performancewest.net
authorized as an agent in FCC CORES. The chrome-extension helper handles
this human-in-the-loop onboarding; it is a one-time-per-carrier step.
Idempotency: if ``already_filed(entity_id, "rmd")`` is True, the handler
skips the portal submission and returns just the packet (the customer
still gets their documents for their records).
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
from .telecom import filing_state
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay
logger = logging.getLogger(__name__)
FCC_RMD_URL = os.environ.get("FCC_RMD_URL", "https://apps.fcc.gov/rmd/")
FCC_CORES_STORAGE_STATE = os.environ.get(
"FCC_CORES_STORAGE_STATE",
"/app/data/fcc_cores_session.json",
)
class RMDFilingHandler(BaseServiceHandler):
SERVICE_SLUG = "rmd-filing"
SERVICE_NAME = "RMD Registration / Recertification"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
date_str = datetime.now().strftime("%Y%m%d")
# ── Guard: require entity data before generating documents ───────
legal_name = entity.get("legal_name", "").strip()
if not legal_name:
logger.warning(
"RMDFilingHandler: no entity data for %s — pausing for intake",
order_number,
)
self._request_entity_intake(order_data)
return []
generated: list[str] = []
# ── 1. Generate the signed-ready packet ─────────────────────────
generated.extend(self._build_packet(order_number, entity, work_dir, date_str))
# ── 2. Idempotency gate ──────────────────────────────────────────
if entity_id and filing_state.already_filed(entity_id, "rmd"):
logger.info(
"RMDFilingHandler: RMD already on file for entity %s — returning packet only",
entity_id,
)
return generated
# ── 2b. Client review gate ─────────────────────────────────────
# The client MUST review and approve the certification before we
# submit. The perjury declaration (47 CFR § 1.16) is on them.
client_approved = order_data.get("client_approved", False)
if not client_approved:
# Upload documents to MinIO for the review portal
from scripts.document_gen import MinioStorage
storage = MinioStorage()
minio_paths = []
for path in generated:
if path.endswith(".pdf"):
remote = f"compliance/{order_number}/{os.path.basename(path)}"
try:
storage.upload_file(path, remote)
minio_paths.append(remote)
except Exception as exc:
logger.warning("MinIO upload failed for %s: %s", path, exc)
# Update order with review status + send review link
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
cur.execute(
"""UPDATE compliance_orders
SET rmd_review_status = 'pending',
rmd_packet_minio_paths = %s
WHERE order_number = %s""",
(minio_paths, order_number),
)
conn.commit()
cur.close()
conn.close()
except Exception as exc:
logger.warning("Could not update review status: %s", exc)
# Send review link email to client
try:
customer_email = order_data.get("customer_email") or entity.get("contact_email")
customer_name = order_data.get("customer_name") or entity.get("contact_name", "")
if customer_email:
# Build JWT review URL
try:
import jwt as pyjwt
except ImportError:
import PyJWT as pyjwt # type: ignore
secret = os.environ.get("CUSTOMER_JWT_SECRET", "changeme")
domain = os.environ.get("DOMAIN", "performancewest.net")
token = pyjwt.encode(
{"order_id": order_number, "order_type": "compliance", "email": customer_email},
secret, algorithm="HS256",
)
review_url = f"https://{domain}/portal/rmd-review?token={token}"
if customer_email:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
subject = f"Action Required — Review your RMD certification for {entity.get('legal_name', order_number)}"
body = (
f"<h2>Your RMD Certification is Ready for Review</h2>"
f"<p>Hi {customer_name.split(' ')[0] if customer_name else 'there'},</p>"
f"<p>We've prepared your Robocall Mitigation Database certification for "
f"<strong>{entity.get('legal_name', '')}</strong> (FRN: {entity.get('frn', '')}).</p>"
f"<p>Before we submit this to the FCC, we need you to review the filing details "
f"and confirm everything is accurate. This is required because the certification "
f"includes a declaration under penalty of perjury (47 CFR § 1.16).</p>"
f"<p><a href='{review_url}' style='display:inline-block;background:#1e3a5f;color:#fff;"
f"padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600;'>"
f"Review & Approve Filing →</a></p>"
f"<p style='font-size:12px;color:#9ca3af;'>This link expires in 72 hours.</p>"
)
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", "")
smtp_pass = os.environ.get("SMTP_PASS", "")
smtp_from = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>")
if smtp_user and smtp_pass:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = customer_email
msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info("RMD review link sent to %s for %s", customer_email, order_number)
except Exception as exc:
logger.warning("Could not send RMD review email: %s", exc)
logger.info(
"RMDFilingHandler: paused for client review — order %s",
order_number,
)
return generated
# ── 2a. Auto-filing toggle — admin review when disabled ─────────
decision = check_auto_filing(order_data)
if not decision.may_submit:
logger.info(
"RMDFilingHandler: %s — staging for admin review (order=%s)",
decision.reason, order_number,
)
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in generated],
admin_email=decision.admin_email,
summary=(
f"RMD certification packet ready. STIR/SHAKEN posture: "
f"{entity.get('stir_shaken_status', 'N/A')}. "
f"Clicking Approve & File re-runs this handler against the FCC "
f"RMD portal at {FCC_RMD_URL}."
),
)
return generated
# ── 3. Submit to the FCC RMD portal ──────────────────────────────
confirmation_path, confirmation_number = await self._submit_to_rmd(
order_number=order_number,
entity=entity,
packet_docx=next((p for p in generated if p.endswith(".docx")), None),
work_dir=work_dir,
)
if confirmation_path:
generated.append(confirmation_path)
# ── 4. Persist success + email confirmation to client ──────────
if entity_id and confirmation_number:
filing_state.record_rmd_filing(entity_id, confirmation_number)
if confirmation_number:
try:
from scripts.workers.job_server import _send_filing_confirmation
from scripts.document_gen import MinioStorage
customer_email = order_data.get("customer_email") or entity.get("contact_email")
customer_name = order_data.get("customer_name") or entity.get("contact_name", "")
if customer_email:
conf_paths = [p for p in generated if "confirmation" in p.lower()]
_send_filing_confirmation(
customer_email=customer_email,
customer_name=customer_name,
order_number=order_number,
service_name=self.SERVICE_NAME,
confirmation_number=confirmation_number,
authority="FCC Robocall Mitigation Database (RMD)",
minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in conf_paths],
storage=MinioStorage(),
)
except Exception as exc:
logger.warning("RMD filing confirmation email failed: %s", exc)
return generated
# ------------------------------------------------------------------ #
# Packet generation (shared with the checkup handler)
# ------------------------------------------------------------------ #
def _build_packet(
self,
order_number: str,
entity: dict,
work_dir: str,
date_str: str,
) -> list[str]:
"""Produce the RMD cert letter (+ Exhibit A if partial STIR/SHAKEN)."""
from scripts.document_gen.templates.rmd_letter_generator import (
generate_rmd_letter,
_determine_primary_role,
)
files: list[str] = []
rmd_docx = os.path.join(
work_dir, f"rmd_certification_letter_{order_number}_{date_str}.docx"
)
letter = generate_rmd_letter(
entity_name=entity.get("legal_name", ""),
dba_name=entity.get("dba_name", ""),
frn=entity.get("frn", ""),
rmd_number=entity.get("rmd_number", ""),
filer_id_499=entity.get("filer_id_499", ""),
address_street=entity.get("address_street", ""),
address_city=entity.get("address_city", ""),
address_state=entity.get("address_state", ""),
address_zip=entity.get("address_zip", ""),
contact_name=entity.get("contact_name", ""),
contact_title=entity.get("contact_title", ""),
contact_email=entity.get("contact_email", ""),
contact_phone=entity.get("contact_phone", ""),
ceo_name=entity.get("ceo_name", ""),
ceo_title=entity.get("ceo_title", "Chief Executive Officer"),
carrier_category=entity.get("carrier_category", "interconnected_voip"),
infra_type=entity.get("infra_type", "facilities"),
is_wholesale=entity.get("is_wholesale", False),
is_gateway_provider=entity.get("is_gateway_provider", False),
is_international_only=entity.get("is_international_only", False),
uses_ucaas_provider=entity.get("uses_ucaas_provider", False),
carrier_metadata=entity.get("carrier_metadata", {}),
stir_shaken_status=entity.get("stir_shaken_status", "complete_implementation"),
stir_shaken_cert_authority=entity.get("stir_shaken_cert_authority", ""),
upstream_provider_name=entity.get("upstream_provider_name", ""),
upstream_provider_frn=entity.get("upstream_provider_frn", ""),
output_path=rmd_docx,
)
if letter:
files.append(letter)
try:
files.append(self._convert_to_pdf(letter))
except Exception as exc:
logger.warning("RMD letter PDF conversion failed: %s", exc)
stir_status = entity.get("stir_shaken_status", "complete_implementation")
if stir_status in (
"partial_implementation",
"robocall_mitigation_only",
"exempt_small_carrier",
):
from scripts.document_gen.templates.rmd_exhibit_a_generator import (
generate_exhibit_a,
)
role = _determine_primary_role(
is_gateway_provider=entity.get("is_gateway_provider", False),
uses_ucaas_provider=entity.get("uses_ucaas_provider", False),
is_wholesale=entity.get("is_wholesale", False),
is_international_only=entity.get("is_international_only", False),
infra_type=entity.get("infra_type", "facilities"),
)
exhibit_docx = os.path.join(
work_dir,
f"robocall_mitigation_program_{order_number}_{date_str}.docx",
)
exhibit = generate_exhibit_a(
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
carrier_role=role,
carrier_metadata=entity.get("carrier_metadata", {}),
upstream_provider_name=entity.get("upstream_provider_name", ""),
llm_generate=self._call_llm,
output_path=exhibit_docx,
)
if exhibit:
files.append(exhibit)
try:
files.append(self._convert_to_pdf(exhibit))
except Exception as exc:
logger.warning("Exhibit A PDF conversion failed: %s", exc)
return files
# ------------------------------------------------------------------ #
# FCC RMD Playwright submission
# ------------------------------------------------------------------ #
async def _submit_to_rmd(
self,
*,
order_number: str,
entity: dict,
packet_docx: str | None,
work_dir: str,
) -> tuple[str | None, str]:
"""Submit the RMD certification and return (confirmation_pdf, conf_number).
Returns ``(None, "")`` if the submission could not be attempted
(e.g. no authorized CORES session available). In that case the
customer still receives the packet; an admin ToDo is created so a
human can authorize the FRN and the handler can be replayed.
"""
frn = entity.get("frn", "").strip()
if not frn:
logger.warning("RMDFilingHandler: no FRN — skipping portal submission")
self._create_admin_todo(
order_number,
"RMD filing requires an FRN but none was on file for this carrier. "
"Capture the FRN on the telecom entity and re-dispatch.",
)
return None, ""
storage_exists = os.path.exists(FCC_CORES_STORAGE_STATE)
storage_state = FCC_CORES_STORAGE_STATE if storage_exists else None
confirmation_path = os.path.join(
work_dir, f"rmd_confirmation_{order_number}.pdf"
)
confirmation_number = ""
try:
async with undetected_browser(
headless=True,
storage_state=storage_state,
) as (ctx, page):
await page.goto(FCC_RMD_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
# If we weren't handed a logged-in session, redirect to
# CORES login will fire — detect that and fall through to
# the admin-ToDo path.
if "login" in page.url.lower() or "coresWeb" in page.url:
logger.warning(
"RMDFilingHandler: CORES session not authorized for FRN %s; "
"admin must run chrome-extension FCC access helper first",
frn,
)
self._create_admin_todo(
order_number,
f"RMD portal required CORES login for FRN {frn}. "
"Run the FCC Access Helper Chrome extension to authorize "
"filings@performancewest.net on this carrier's FRN, export "
f"the session to {FCC_CORES_STORAGE_STATE}, then re-dispatch "
f"order {order_number}.",
)
return None, ""
# Navigate to the certification form. The real selectors are
# pulled from a live recon session (TODO before production —
# covered by state-automation-status.md pattern).
await page.wait_for_selector("text=Certification", timeout=20000)
await page.click("text=File Certification")
await human_delay()
# Pre-populate the form from entity data.
await page.fill('input[name="frn"]', frn)
await page.fill(
'input[name="company_legal_name"]',
entity.get("legal_name", ""),
)
# Attach the signed-ready packet DOCX (plus PDF if present).
if packet_docx:
await page.set_input_files(
'input[type="file"][name="certification_doc"]',
packet_docx,
)
# STIR/SHAKEN implementation status — value must match the
# RMD form radio options.
stir_status = entity.get("stir_shaken_status", "complete_implementation")
await page.click(f'input[name="stir_shaken_status"][value="{stir_status}"]')
await human_delay()
# Review + submit.
await page.click('button[type="submit"]')
await page.wait_for_selector(
"text=Confirmation Number", timeout=60000
)
await human_delay(2.0, 4.0)
# Capture confirmation.
body = await page.locator("body").inner_text()
for line in body.splitlines():
if "Confirmation Number" in line:
parts = line.split(":", 1)
if len(parts) == 2:
confirmation_number = parts[1].strip()
break
# Save the confirmation page as PDF.
await page.pdf(path=confirmation_path, format="Letter")
logger.info(
"RMDFilingHandler: submitted FRN %s, confirmation %s",
frn,
confirmation_number,
)
return confirmation_path, confirmation_number
except Exception as exc:
logger.exception("RMDFilingHandler: portal submission failed: %s", exc)
self._create_admin_todo(
order_number,
f"Automated RMD submission failed for FRN {frn}: {exc}. "
"Inspect worker logs for the screenshot/video, then file manually.",
)
return None, ""
# ------------------------------------------------------------------ #
# Admin fallback
# ------------------------------------------------------------------ #
def _create_admin_todo(self, order_number: str, description: str) -> None:
"""Create an ERPNext ToDo so a human can unblock the filing."""
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

View file

@ -0,0 +1,221 @@
"""State PUC/PSC Registration handler.
Processes per-state PUC registrations for VoIP, broadband, and CLEC
providers. Fans out one `state_puc_registrations` row per selected
state and creates admin todos for manual filing.
States without VoIP registration requirements are skipped with a note.
States requiring a surety bond start at 'bond_pending' status.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import psycopg2
from .base_handler import BaseServiceHandler
logger = logging.getLogger(__name__)
DATABASE_URL = os.environ.get("DATABASE_URL", "")
# Our service fee per state
PUC_SERVICE_FEE_CENTS = 39900 # $399/state
class StatePucFilingHandler(BaseServiceHandler):
SERVICE_SLUG = "state-puc"
SERVICE_NAME = "State PUC/PSC Registration"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
order_number = order_data["name"]
entity = order_data.get("entity", {}) or {}
intake = order_data.get("intake_data", {}) or {}
# Target states from intake
target_states = intake.get("target_states") or []
if isinstance(target_states, str):
target_states = [s.strip().upper() for s in target_states.split(",") if s.strip()]
else:
target_states = [str(s).strip().upper() for s in target_states]
if not target_states:
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: no target states specified in intake_data. "
"Admin should add target_states and re-dispatch.",
)
return []
entity_name = (
intake.get("entity_legal_name")
or entity.get("legal_name")
or ""
)
if not entity_name:
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: missing entity legal name. "
"Admin should set entity_legal_name in intake_data.",
)
return []
reg_type = intake.get("registration_type", "voip")
# Normalize: empty string → None (DB CHECK constraint rejects "")
provider_type = intake.get("provider_type") or None
frn = intake.get("frn") or entity.get("frn")
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor() as cur:
for state_code in target_states:
# Look up state PUC requirements
cur.execute(
"""SELECT voip_registration_required, voip_registration_fee_cents,
voip_bond_required, voip_bond_amount_cents,
broadband_registration_required, broadband_registration_fee_cents,
clec_certification_required, clec_certification_fee_cents,
clec_bond_required, clec_bond_amount_cents,
agency_name
FROM state_puc_requirements
WHERE state_code = %s""",
(state_code,),
)
req = cur.fetchone()
if not req:
logger.warning("StatePucHandler: no PUC data for %s", state_code)
continue
(voip_req, voip_fee, voip_bond_req, voip_bond_amt,
bb_req, bb_fee, clec_req, clec_fee, clec_bond_req, clec_bond_amt,
agency_name) = req
# Calculate fees based on registration type
state_fee = 0
bond_amount = 0
required = False
if reg_type in ("voip", "bundle"):
if voip_req:
required = True
state_fee += voip_fee
if voip_bond_req:
bond_amount = max(bond_amount, voip_bond_amt)
if reg_type in ("broadband", "bundle"):
if bb_req:
required = True
state_fee += bb_fee
if reg_type in ("clec", "bundle"):
if clec_req:
required = True
state_fee += clec_fee
if clec_bond_req:
bond_amount = max(bond_amount, clec_bond_amt)
if not required:
logger.info(
"StatePucHandler: %s does not require %s registration — skipping",
state_code, reg_type,
)
continue
# Check for existing active registration
cur.execute(
"""SELECT id FROM state_puc_registrations
WHERE order_number = %s AND state_code = %s
AND status NOT IN ('cancelled','rejected')
LIMIT 1""",
(order_number, state_code),
)
if cur.fetchone():
logger.info(
"StatePucHandler: %s already has %s registration — skipping",
order_number, state_code,
)
continue
co_id = order_data.get("compliance_order_id") or order_data.get("id")
# Initial status: bond_pending if bond required, else filing
initial_status = "bond_pending" if bond_amount > 0 else "filing"
cur.execute(
"""INSERT INTO state_puc_registrations (
compliance_order_id, order_number,
telecom_entity_id, state_code,
registration_type, entity_legal_name, frn,
provider_type,
status,
state_fee_cents, bond_amount_cents,
service_fee_cents, retail_total_cents
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s
)""",
(
co_id, order_number,
entity.get("id"), state_code,
reg_type, entity_name, frn,
provider_type,
initial_status,
state_fee, bond_amount,
PUC_SERVICE_FEE_CENTS,
PUC_SERVICE_FEE_CENTS + state_fee,
),
)
# Create admin todo
bond_note = (
f" BOND REQUIRED: ${bond_amount / 100:,.0f} surety bond. "
"Coordinate with surety provider before filing."
if bond_amount > 0
else ""
)
self._create_admin_todo(
order_number,
f"PUC registration in {state_code} ({agency_name}): "
f"{entity_name}{reg_type} registration. "
f"State fee: ${state_fee / 100:,.0f}.{bond_note} "
f"Provider type: {provider_type or 'not specified'}. "
f"FRN: {frn or 'N/A'}.",
)
conn.commit()
logger.info(
"StatePucHandler: created registration(s) for %s across %d state(s)",
order_number, len(target_states),
)
except Exception as exc:
logger.exception(
"StatePucHandler: DB error for %s: %s", order_number, exc,
)
conn.rollback()
self._create_admin_todo(
order_number,
f"{self.SERVICE_NAME}: failed to create registration rows — {exc}",
)
return []
finally:
conn.close()
return []
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "Medium",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)

View file

@ -0,0 +1,115 @@
"""STIR/SHAKEN Implementation Assistance handler.
STIR/SHAKEN has two moving pieces:
1. The STI Certification Authority (STI-CA) issues the actual digital
certificate used to sign calls. This is an external vendor handoff
(Peeringhub, iconectiv, Neustar, TransNexus, etc.) we cannot file
this automatically on the customer's behalf.
2. The carrier's STIR/SHAKEN implementation status must be accurately
reflected in the Robocall Mitigation Database (RMD). Our RMD filing
handler already drives that portal; we reuse it here.
This handler:
* Reuses ``RMDFilingHandler`` to update the RMD entry with the new
STIR/SHAKEN posture (e.g., moving from ``robocall_mitigation_only`` to
``partial_implementation``).
* Creates an ERPNext ToDo assigned to the Accounting Advisor to shepherd
the customer through the STI-CA cert provisioning with the vendor of
their choice. The ToDo captures the carrier's preferred vendor from
the intake data if present.
* On successful STI-CA issuance (recorded manually on the telecom_entity
through the admin UI), the next compliance checkup will see
``stir_shaken_cert_issued_at`` populated and flip the STIR/SHAKEN check
from red/yellow to green.
"""
from __future__ import annotations
import logging
from .base_handler import BaseServiceHandler
from .rmd_filing import RMDFilingHandler
logger = logging.getLogger(__name__)
class StirShakenHandler(BaseServiceHandler):
SERVICE_SLUG = "stir-shaken"
SERVICE_NAME = "STIR/SHAKEN Implementation Assistance"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
order_number = order_data["name"]
entity = order_data.get("entity", {})
intake = order_data.get("intake_data") or {}
# ── 1. Update RMD entry with new STIR/SHAKEN status ────────────
# The customer's order intake should carry the target posture; if
# not, we default to partial_implementation (most common uplift
# path for non-facilities-based carriers).
target_status = (
intake.get("target_stir_shaken_status")
or entity.get("stir_shaken_status")
or "partial_implementation"
)
entity_with_target = dict(entity)
entity_with_target["stir_shaken_status"] = target_status
rmd_handler = RMDFilingHandler()
rmd_order = dict(order_data)
rmd_order["entity"] = entity_with_target
generated = await rmd_handler.process(rmd_order)
# ── 2. Create admin ToDo for STI-CA vendor coordination ─────────
self._create_sti_ca_todo(
order_number=order_number,
entity=entity,
preferred_vendor=intake.get("sti_ca_vendor", ""),
current_status=entity.get("stir_shaken_status", ""),
target_status=target_status,
)
return generated
def _create_sti_ca_todo(
self,
*,
order_number: str,
entity: dict,
preferred_vendor: str,
current_status: str,
target_status: str,
) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
description = (
f"[{self.SERVICE_SLUG}] {order_number}\n\n"
f"Coordinate STI-CA digital certificate issuance with the "
f"carrier's chosen STI Certification Authority.\n\n"
f"Carrier: {entity.get('legal_name', '')}\n"
f"FRN: {entity.get('frn', 'N/A')}\n"
f"Current STIR/SHAKEN posture: {current_status or 'N/A'}\n"
f"Target STIR/SHAKEN posture: {target_status}\n"
f"Preferred STI-CA vendor (if specified): {preferred_vendor or 'not specified'}\n\n"
f"Steps:\n"
f" 1. Introduce customer to chosen STI-CA (Peeringhub, iconectiv, "
f"Neustar, TransNexus, etc.).\n"
f" 2. Coordinate OCN / SPC code issuance (via iconectiv) if needed.\n"
f" 3. Track cert issuance; when received, update telecom_entities."
f"stir_shaken_cert_issued_at and stir_shaken_cert_authority.\n"
f" 4. Run the compliance checkup again to confirm STIR/SHAKEN check "
f"flips green.\n"
)
ERPNextClient().create_resource(
"ToDo",
{
"description": description,
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create STI-CA ToDo: %s", exc)

View file

@ -0,0 +1 @@
"""Telecom compliance service helpers (undetected browser, FCC/USAC adapters)."""

View file

@ -0,0 +1,307 @@
"""Global auto-filing toggle + admin-review workflow.
Gates the Playwright submission step of every FCC/USAC/BDC filing handler
so that by default no filing is submitted to the FCC without a
Performance West admin reviewing the generated packet first.
Design
------
* Settings live in ERPNext ``Compliance Settings`` (single DocType) under
the ``auto_filing_enabled`` Check field. Default = False (safer).
* A per-order override field (``custom_auto_filing_override`` on the Sales
Order) lets the admin approve a specific filing one-shot after review
without flipping the global toggle.
* When auto-filing is OFF and there's no per-order override, the handler:
1. Produces and uploads the packet as normal.
2. Creates an ERPNext ToDo assigned to ``admin_email`` (default
``ops@performancewest.net``) with a summary + "Approve & File"
CTA link.
3. Sends the admin a short HTML email with the same summary + link.
4. Leaves the order in "Awaiting Admin Review" state the workflow
picks it back up when the admin clicks Approve.
The Approve-and-File link hits the Express API endpoint
``POST /api/v1/compliance-orders/:order_number/approve-and-file`` which
sets ``custom_auto_filing_override = 1`` on the Sales Order and
re-dispatches the handler. Handler reads the override flag and runs
the Playwright submission even if the global toggle is still off.
Env fallback
------------
If ERPNext is unreachable (e.g. during local development), the module
reads the env var ``AUTO_FILING_ENABLED`` as a truthy override. Admin
email falls back to ``ADMIN_EMAIL`` env var, then
``ops@performancewest.net``.
"""
from __future__ import annotations
import logging
import os
import smtplib
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
logger = logging.getLogger(__name__)
DEFAULT_ADMIN_EMAIL = "ops@performancewest.net"
# ─── Settings lookup ──────────────────────────────────────────────────────
@dataclass
class AutoFilingDecision:
"""Result of ``check_auto_filing`` — caller branches on ``may_submit``."""
may_submit: bool
reason: str
admin_email: str
global_enabled: bool
order_override: bool
def _env_truthy(value: str | None) -> bool:
if not value:
return False
return value.strip().lower() in ("1", "true", "yes", "on")
def _settings_from_erpnext() -> tuple[bool, str]:
"""Read ``Compliance Settings`` from ERPNext — returns (enabled, admin_email)."""
try:
from scripts.workers.erpnext_client import ERPNextClient
erp = ERPNextClient()
settings = erp.get_resource("Compliance Settings", "Compliance Settings")
if isinstance(settings, list):
settings = settings[0] if settings else {}
enabled = bool(settings.get("auto_filing_enabled", 0))
admin = (
settings.get("admin_email")
or os.environ.get("ADMIN_EMAIL")
or DEFAULT_ADMIN_EMAIL
)
return enabled, admin
except Exception as exc:
logger.debug("auto_filing: ERPNext settings read failed: %s", exc)
return _env_truthy(os.environ.get("AUTO_FILING_ENABLED")), (
os.environ.get("ADMIN_EMAIL") or DEFAULT_ADMIN_EMAIL
)
def check_auto_filing(order_data: dict) -> AutoFilingDecision:
"""Resolve whether the calling handler may submit to the FCC/USAC.
The handler passes the current ``order_data`` (as received from
``job_server.py``). We inspect the per-order override first, then
fall back to the global setting.
"""
order_override = bool(order_data.get("custom_auto_filing_override", 0))
global_enabled, admin_email = _settings_from_erpnext()
if order_override:
return AutoFilingDecision(
may_submit=True,
reason="per-order admin override",
admin_email=admin_email,
global_enabled=global_enabled,
order_override=True,
)
if global_enabled:
return AutoFilingDecision(
may_submit=True,
reason="global auto_filing_enabled",
admin_email=admin_email,
global_enabled=True,
order_override=False,
)
return AutoFilingDecision(
may_submit=False,
reason="auto-filing disabled; admin review required",
admin_email=admin_email,
global_enabled=False,
order_override=False,
)
# ─── Admin review hand-off ────────────────────────────────────────────────
def request_admin_review(
*,
order_number: str,
service_slug: str,
service_name: str,
entity_name: str,
frn: str,
packet_minio_paths: list[str],
admin_email: str,
summary: str = "",
) -> None:
"""Create an ERPNext ToDo + send a short email to the admin.
The email and the ToDo both include a one-click "Approve & File" URL
that re-dispatches the handler for this order with the auto-filing
override set.
"""
api_base = os.environ.get("API_URL", "http://api:3001").rstrip("/")
approve_url = (
f"{api_base}/api/v1/compliance-orders/{order_number}/approve-and-file"
)
todo_description = (
f"[{service_slug}] Admin review required for {order_number}\n\n"
f"Carrier: {entity_name}\n"
f"FRN: {frn or 'N/A'}\n"
f"Service: {service_name}\n\n"
f"Auto-filing is disabled. The generated packet is staged in MinIO — "
f"review it, then POST to the approve-and-file URL (or click the "
f"button in the admin email) to submit the filing to the FCC.\n\n"
f"Files in MinIO:\n"
+ "\n".join(f"{p}" for p in packet_minio_paths)
+ f"\n\nApprove & File:\n {approve_url}\n"
)
if summary:
todo_description += f"\nHandler notes:\n{summary}\n"
_create_todo(todo_description, admin_email)
_send_admin_email(
to_email=admin_email,
order_number=order_number,
service_name=service_name,
entity_name=entity_name,
frn=frn,
packet_minio_paths=packet_minio_paths,
approve_url=approve_url,
summary=summary,
)
def _create_todo(description: str, admin_email: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": description,
"priority": "High",
"allocated_to": admin_email,
"status": "Open",
},
)
except Exception as exc:
logger.warning("auto_filing: could not create admin ToDo: %s", exc)
_EMAIL_TMPL = """\
<html>
<body style="font-family:Arial,Helvetica,sans-serif;color:#1f2937;">
<h2 style="color:#1a2744;margin:0 0 12px;">Filing ready for review</h2>
<p><strong>Order:</strong> {order_number}<br>
<strong>Carrier:</strong> {entity_name}<br>
<strong>FRN:</strong> {frn}<br>
<strong>Service:</strong> {service_name}</p>
<p>Auto-filing is <em>disabled</em>. The generated packet is staged in MinIO.
Review the documents, then click below to submit the filing to the FCC.</p>
<p><a href="{approve_url}"
style="display:inline-block;padding:12px 22px;background:#059669;
color:#fff;border-radius:4px;font-weight:600;text-decoration:none;">
Approve &amp; File
</a></p>
<h3 style="margin:22px 0 6px;color:#1a2744;">Packet files</h3>
<ul style="margin:0 0 12px 18px;padding:0;">
{files}
</ul>
{summary_block}
<p style="color:#64748b;font-size:12px;margin-top:24px;">
To enable auto-filing globally, set <code>auto_filing_enabled = 1</code>
in the ERPNext Compliance Settings doctype (or set
<code>AUTO_FILING_ENABLED=1</code> in the worker environment).
</p>
</body>
</html>
"""
def _send_admin_email(
*,
to_email: str,
order_number: str,
service_name: str,
entity_name: str,
frn: str,
packet_minio_paths: list[str],
approve_url: str,
summary: str,
) -> None:
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", "")
smtp_pass = os.environ.get("SMTP_PASSWORD", "")
smtp_from = os.environ.get("SMTP_FROM", "orders@performancewest.net")
if not smtp_user or not smtp_pass:
logger.warning("auto_filing: SMTP not configured — skipping admin email")
return
files_html = "\n ".join(
f"<li style=\"margin:3px 0;\"><code>{p}</code></li>"
for p in packet_minio_paths
) or "<li><em>(none)</em></li>"
summary_block = (
f"<h3 style=\"margin:22px 0 6px;color:#1a2744;\">Handler notes</h3>"
f"<pre style=\"background:#f8fafc;padding:10px;border-radius:4px;"
f"white-space:pre-wrap;font-size:12px;\">{summary}</pre>"
if summary else ""
)
html = _EMAIL_TMPL.format(
order_number=order_number,
entity_name=entity_name,
frn=frn or "N/A",
service_name=service_name,
approve_url=approve_url,
files=files_html,
summary_block=summary_block,
)
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Review & File] {order_number}{service_name}"
msg["From"] = smtp_from
msg["To"] = to_email
msg.attach(MIMEText(html, "html"))
try:
with smtplib.SMTP(smtp_host, smtp_port) as server:
if smtp_port != 25:
server.starttls()
server.login(smtp_user, smtp_pass)
server.sendmail(smtp_from, [to_email], msg.as_string())
logger.info("auto_filing: admin review email sent to %s", to_email)
except Exception as exc:
logger.warning("auto_filing: could not send admin email: %s", exc)
# ─── Per-order override writer (used by the API approve-and-file endpoint) ─
def set_order_override(sales_order_name: str) -> bool:
"""Flip ``custom_auto_filing_override`` to 1 on the Sales Order."""
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().set_value(
"Sales Order",
sales_order_name,
"custom_auto_filing_override",
1,
)
return True
except Exception as exc:
logger.warning("auto_filing: could not set override: %s", exc)
return False

View file

@ -0,0 +1,333 @@
"""FCC Form 499-A shared utilities.
De minimis calculator (Appendix A), safe-harbor percentage lookup, Line 612
filing-type detection, and Line 105 box-tick derivation.
Used by form_499a.py, form_499_initial.py, and the /validate endpoint's
Python counterpart (if we ever move validation server-side).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Optional
import psycopg2
import os
logger = logging.getLogger(__name__)
# ── Line 105 box-tick derivation ────────────────────────────────────────
# Mirrors site/src/lib/line_105_catalog.ts::derivedLine105Boxes. When a
# CLEC/IXC/Wireless has infra_type='reseller' or 'mvno', the handler
# automatically ticks the corresponding derived Line 105 box on the form.
LINE_105_BOX_NUMBERS = {
"voip_interconnected": 1,
"voip_non_interconnected": 2,
"clec": 3,
"ilec": 4,
"local_reseller": 5, # derived from clec + reseller
"toll_reseller": 6, # derived from ixc + reseller
"ixc": 7,
"wireless": 8,
"mvno": 9, # derived from wireless + mvno
"prepaid_calling_card": 10,
"private_line": 11,
"satellite": 12,
"payphone": 13,
"osp": 14,
"shared_tenant": 15,
"audio_bridging": 16,
"toll_free": 17,
"paging": 18,
"smr": 19,
"fixed_wireless": 20,
"mobile_satellite": 21,
"other": 22,
}
def derived_line_105_boxes(category_id: str, infra_type: Optional[str]) -> list[int]:
"""Return extra Line 105 boxes to tick because of the infra_type flag."""
boxes: list[int] = []
if infra_type == "reseller":
if category_id == "clec":
boxes.append(5)
elif category_id == "ixc":
boxes.append(6)
if infra_type == "mvno" and category_id == "wireless":
boxes.append(9)
return boxes
def all_line_105_boxes_to_tick(line_105_categories: list[dict]) -> list[int]:
"""Return every Line 105 box number to tick for this filer."""
boxes: set[int] = set()
for cat in line_105_categories or []:
cat_id = cat.get("id")
if cat_id and cat_id in LINE_105_BOX_NUMBERS:
boxes.add(LINE_105_BOX_NUMBERS[cat_id])
boxes.update(derived_line_105_boxes(cat_id, cat.get("infra_type")))
return sorted(boxes)
# ── Safe harbor lookup ──────────────────────────────────────────────────
SAFE_HARBOR_DISALLOWED_CATEGORIES = {"voip_non_interconnected"}
def _db_connect():
return psycopg2.connect(os.environ.get("DATABASE_URL", ""))
def load_safe_harbor_pct(form_year: int, category_id: str) -> Optional[float]:
"""Return the safe-harbor interstate % for (year, category), or None.
None is returned for categories that have no safe harbor (e.g.,
non-interconnected VoIP) or if the year/category combination isn't in
the fcc_safe_harbor_percentages seed table.
"""
if category_id in SAFE_HARBOR_DISALLOWED_CATEGORIES:
return None
try:
conn = _db_connect()
with conn.cursor() as cur:
cur.execute(
"SELECT interstate_pct FROM fcc_safe_harbor_percentages "
"WHERE form_year = %s AND line_105_category = %s",
(form_year, category_id),
)
row = cur.fetchone()
conn.close()
return float(row[0]) if row else None
except Exception as exc:
logger.warning("safe-harbor lookup failed: %s", exc)
return None
def safe_harbor_allowed(category_id: str) -> bool:
return category_id not in SAFE_HARBOR_DISALLOWED_CATEGORIES
# ── De minimis calculator (Appendix A, 11-line worksheet) ───────────────
@dataclass
class DeMinimisWorksheet:
"""Appendix A de minimis determination worksheet.
Every field corresponds to a line in the 2026 Form 499-A Appendix A.
Mirrors the PDF layout exactly so auditors can follow along.
"""
form_year: int
# Lines 1-4 — interstate/intl contribution bases for filer + affiliates
line_1_filer_interstate_cents: int = 0
line_2_filer_intl_cents: int = 0
line_3_affiliates_interstate_cents: int = 0
line_4_affiliates_intl_cents: int = 0
# Line 5 — consolidated interstate
line_5_consolidated_interstate_cents: int = 0
# Line 6 — consolidated interstate+intl (pre-LIRE exclusion)
line_6_consolidated_total_cents: int = 0
# Line 7 — interstate as % of consolidated total (LIRE test)
line_7_interstate_pct: float = 0.0
# Line 8 — LIRE exempt? (line_7 ≤ 12%)
line_8_lire_exempt: bool = False
# Line 9 — contribution base to test
line_9_contribution_base_cents: int = 0
# Line 10 — year-specific factor (0.256 for 2026)
line_10_factor: float = 0.0
# Line 11 — estimated annual contribution = line_9 × line_10
line_11_estimated_contrib_cents: int = 0
# Result
is_de_minimis: bool = False
threshold_usd: int = 10000
notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"form_year": self.form_year,
"line_1_filer_interstate_cents": self.line_1_filer_interstate_cents,
"line_2_filer_intl_cents": self.line_2_filer_intl_cents,
"line_3_affiliates_interstate_cents": self.line_3_affiliates_interstate_cents,
"line_4_affiliates_intl_cents": self.line_4_affiliates_intl_cents,
"line_5_consolidated_interstate_cents": self.line_5_consolidated_interstate_cents,
"line_6_consolidated_total_cents": self.line_6_consolidated_total_cents,
"line_7_interstate_pct": self.line_7_interstate_pct,
"line_8_lire_exempt": self.line_8_lire_exempt,
"line_9_contribution_base_cents": self.line_9_contribution_base_cents,
"line_10_factor": self.line_10_factor,
"line_11_estimated_contrib_cents": self.line_11_estimated_contrib_cents,
"is_de_minimis": self.is_de_minimis,
"threshold_usd": self.threshold_usd,
"notes": self.notes,
}
def load_deminimis_factor(form_year: int) -> float:
"""Return the Appendix A Line 10 factor for a form year.
Raises ValueError if the year isn't in the seed table — an unknown
form year is a code bug, not a graceful-fallback situation.
"""
try:
conn = _db_connect()
with conn.cursor() as cur:
cur.execute(
"SELECT factor FROM fcc_deminimis_factors WHERE form_year = %s",
(form_year,),
)
row = cur.fetchone()
conn.close()
except Exception as exc:
logger.error("deminimis factor lookup failed: %s", exc)
raise
if not row:
raise ValueError(f"No de minimis factor configured for form year {form_year}")
return float(row[0])
def calculate_de_minimis(
*,
form_year: int,
filer_total_revenue_cents: int,
filer_interstate_pct: float,
filer_international_pct: float,
affiliates: Optional[list[dict]] = None,
) -> DeMinimisWorksheet:
"""Compute the Appendix A de minimis worksheet.
affiliates is a list of {total_revenue_cents, interstate_pct,
international_pct} records for each affiliated filer. Empty list =
no affiliates.
"""
affiliates = affiliates or []
w = DeMinimisWorksheet(form_year=form_year)
# Line 1: filer interstate revenue
w.line_1_filer_interstate_cents = int(
filer_total_revenue_cents * (filer_interstate_pct / 100.0)
)
# Line 2: filer international revenue
w.line_2_filer_intl_cents = int(
filer_total_revenue_cents * (filer_international_pct / 100.0)
)
# Lines 3-4: affiliates
for a in affiliates:
tot = int(a.get("total_revenue_cents", 0))
ipct = float(a.get("interstate_pct", 0))
intl = float(a.get("international_pct", 0))
w.line_3_affiliates_interstate_cents += int(tot * ipct / 100.0)
w.line_4_affiliates_intl_cents += int(tot * intl / 100.0)
# Line 5: consolidated interstate
w.line_5_consolidated_interstate_cents = (
w.line_1_filer_interstate_cents + w.line_3_affiliates_interstate_cents
)
# Line 6: consolidated interstate + intl
w.line_6_consolidated_total_cents = w.line_5_consolidated_interstate_cents + (
w.line_2_filer_intl_cents + w.line_4_affiliates_intl_cents
)
# Line 7: interstate as % of consolidated total (guard /0)
if w.line_6_consolidated_total_cents > 0:
w.line_7_interstate_pct = round(
100.0 * w.line_5_consolidated_interstate_cents /
w.line_6_consolidated_total_cents,
4,
)
else:
w.line_7_interstate_pct = 0.0
# Line 8: LIRE exempt if interstate ≤ 12% of combined
w.line_8_lire_exempt = w.line_7_interstate_pct <= 12.0
# Line 9: contribution base to test — interstate + (0 if LIRE else intl)
intl_total = w.line_2_filer_intl_cents + w.line_4_affiliates_intl_cents
w.line_9_contribution_base_cents = (
w.line_5_consolidated_interstate_cents +
(0 if w.line_8_lire_exempt else intl_total)
)
# Line 10: year factor
w.line_10_factor = load_deminimis_factor(form_year)
# Line 11: estimated annual contribution
w.line_11_estimated_contrib_cents = int(
w.line_9_contribution_base_cents * w.line_10_factor
)
# Result: de minimis if < $10,000
w.is_de_minimis = w.line_11_estimated_contrib_cents < (w.threshold_usd * 100)
if w.is_de_minimis:
w.notes.append(
f"De minimis: estimated contribution ${w.line_11_estimated_contrib_cents/100:,.2f}"
f" < ${w.threshold_usd:,.0f} threshold."
)
else:
w.notes.append(
f"NOT de minimis: estimated contribution ${w.line_11_estimated_contrib_cents/100:,.2f}"
f" ≥ ${w.threshold_usd:,.0f} threshold."
)
if w.line_8_lire_exempt:
w.notes.append(
f"LIRE exempt: interstate ({w.line_7_interstate_pct:.2f}%) ≤ 12% "
f"of combined interstate+intl — international revenue excluded."
)
return w
# ── Line 612 filing-type detection ──────────────────────────────────────
def detect_filing_type(
*,
entity: dict,
current_year_filing_exists: bool = False,
revised_reason: Optional[str] = None,
) -> str:
"""Return one of: original_april_1, registration_new_filer,
revised_registration, revised_revenue.
"""
if not entity.get("filer_id_499"):
return "registration_new_filer"
if current_year_filing_exists:
if revised_reason == "registration":
return "revised_registration"
if revised_reason == "revenue":
return "revised_revenue"
return "original_april_1"
# ── TRS contribution base (Lines 512-514) ───────────────────────────────
# Revenue lines that roll up into the TRS contribution base.
# Line 418.4 (non-interconnected VoIP) is included ONLY in TRS base —
# it's excluded from USF/NANPA/LNP/ITSP bases. Line 511 subtracts.
TRS_BASE_LINE_KEYS = [
"line_403", "line_404", "line_404_1", "line_404_3",
"line_405", "line_406", "line_407", "line_408",
"line_409", "line_410", "line_411", "line_412",
"line_413", "line_414_1", "line_414_2",
"line_415", "line_416", "line_417",
"line_418_4", # TRS-only
]
def compute_trs_contribution_base(revenue_lines: dict) -> tuple[int, int, int]:
"""Return (line_512, line_513, line_514) in cents.
line_512 = Σ(TRS_BASE_LINE_KEYS) - line_511
line_513 = line_513 (uncollectible for TRS, provided by filer)
line_514 = line_512 - line_513
"""
line_512 = sum(int(revenue_lines.get(k, 0) or 0) for k in TRS_BASE_LINE_KEYS)
line_512 -= int(revenue_lines.get("line_511", 0) or 0)
line_513 = int(revenue_lines.get("line_513", 0) or 0)
line_514 = line_512 - line_513
return line_512, line_513, line_514

View file

@ -0,0 +1,179 @@
"""Helpers for reading/writing FCC filing state on telecom_entities.
The remediation handlers (RMD, CPNI, 499-A, BDC) all need to:
1. Check whether the filing is already on record for the current cycle
(idempotency don't double-submit if the customer ordered twice).
2. On success, persist the submission timestamp + confirmation number
so the next compliance checkup flips the deficiency to green.
Columns added by migration 047:
* rmd_last_cert_date / rmd_confirmation_number
* cpni_last_cert_date / cpni_confirmation_number
* form_499a_confirmation_number (uses existing last_filing_year)
* bdc_last_filing_date / bdc_confirmation_number
* stir_shaken_cert_issued_at
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
logger = logging.getLogger(__name__)
def _connect():
"""Open a psycopg2 connection using DATABASE_URL. Returns None on failure."""
try:
import psycopg2
return psycopg2.connect(os.environ.get("DATABASE_URL", ""))
except Exception as exc:
logger.error("filing_state: could not connect to PG: %s", exc)
return None
def _get_entity_field(entity_id: int, field: str) -> Optional[object]:
conn = _connect()
if conn is None:
return None
try:
with conn.cursor() as cur:
# Column names come from the handlers, not user input — safe to
# interpolate. psycopg2 does not parameterize identifiers.
cur.execute(
f"SELECT {field} FROM telecom_entities WHERE id = %s", # noqa: S608
(entity_id,),
)
row = cur.fetchone()
return row[0] if row else None
except Exception as exc:
logger.warning("filing_state: read %s.%s failed: %s", entity_id, field, exc)
return None
finally:
conn.close()
def _update_entity(entity_id: int, updates: dict[str, object]) -> bool:
"""UPDATE telecom_entities SET <updates> WHERE id = $id. Returns success."""
if not updates:
return True
conn = _connect()
if conn is None:
return False
try:
set_clause = ", ".join(f"{k} = %s" for k in updates.keys())
values = list(updates.values()) + [entity_id]
with conn.cursor() as cur:
cur.execute(
f"UPDATE telecom_entities SET {set_clause} WHERE id = %s", # noqa: S608
values,
)
conn.commit()
return True
except Exception as exc:
logger.warning("filing_state: update %s failed: %s", entity_id, exc)
return False
finally:
conn.close()
# ── Idempotency windows ─────────────────────────────────────────────────────
#
# RMD recertification is annual; USAC 499-A is annual (due April 1); CPNI is
# annual (due March 1); BDC is twice-yearly (Dec 1 / Jun 1). "Already filed
# this cycle" roughly means the last filing was within the window below.
RMD_CYCLE_DAYS = 365
CPNI_CYCLE_DAYS = 365
FORM_499A_CYCLE_DAYS = 365
BDC_CYCLE_DAYS = 180
def already_filed(
entity_id: int, filing_type: str, filing_year: Optional[int] = None,
) -> bool:
"""Return True if the filing is on record within its cycle window.
``filing_type`` is one of: "rmd", "cpni", "499a", "bdc".
``filing_year`` applies only to 499a callers filing past-due should
pass the target reporting year so we don't skip an older year just
because a newer one was already filed. If not provided, defaults to
the current year (preserving legacy behavior).
"""
column, cycle_days = {
"rmd": ("rmd_last_cert_date", RMD_CYCLE_DAYS),
"cpni": ("cpni_last_cert_date", CPNI_CYCLE_DAYS),
"499a": ("last_filing_year", None), # year-based, handled separately
"bdc": ("bdc_last_filing_date", BDC_CYCLE_DAYS),
}.get(filing_type, (None, None))
if column is None:
return False
value = _get_entity_field(entity_id, column)
if value is None:
return False
if filing_type == "499a":
try:
last_year = int(value)
check_year = filing_year if filing_year is not None else datetime.utcnow().year
# "Already filed" means the entity's last_filing_year equals or
# exceeds the year we're about to file. For past-due filings
# targeting an older year, use that year explicitly.
return last_year >= int(check_year)
except (TypeError, ValueError):
return False
if isinstance(value, str):
try:
value = datetime.fromisoformat(value)
except ValueError:
return False
if not isinstance(value, datetime):
return False
# psycopg2 returns tz-aware datetimes when the column is TIMESTAMPTZ; strip
# tz for the arithmetic below.
if value.tzinfo is not None:
value = value.replace(tzinfo=None)
return value >= datetime.utcnow() - timedelta(days=cycle_days or 0)
# ── Success writers ─────────────────────────────────────────────────────────
def record_rmd_filing(entity_id: int, confirmation_number: str = "") -> bool:
return _update_entity(entity_id, {
"rmd_last_cert_date": datetime.utcnow(),
"rmd_confirmation_number": confirmation_number,
})
def record_cpni_filing(entity_id: int, confirmation_number: str = "") -> bool:
return _update_entity(entity_id, {
"cpni_last_cert_date": datetime.utcnow(),
"cpni_confirmation_number": confirmation_number,
})
def record_form_499a_filing(entity_id: int, confirmation_number: str = "") -> bool:
return _update_entity(entity_id, {
"last_filing_year": datetime.utcnow().year,
"form_499a_confirmation_number": confirmation_number,
})
def record_bdc_filing(entity_id: int, confirmation_number: str = "") -> bool:
return _update_entity(entity_id, {
"bdc_last_filing_date": datetime.utcnow(),
"bdc_confirmation_number": confirmation_number,
})
def record_stir_shaken_cert(entity_id: int, issued_at: Optional[datetime] = None) -> bool:
return _update_entity(entity_id, {
"stir_shaken_cert_issued_at": issued_at or datetime.utcnow(),
})

View file

@ -0,0 +1,221 @@
"""Undetected Playwright launcher for FCC / USAC / BDC portals.
All automated filing handlers (RMD, CPNI, Form 499-A, BDC) go through this
helper instead of importing ``playwright`` directly. We prefer ``patchright``
(a drop-in Playwright replacement that patches ``navigator.webdriver``, CDP
leakage, runtime-enable fingerprints, and the ``--disable-blink-features=
AutomationControlled`` artifact) and fall back to vanilla Playwright with
the same stealth init scripts we use in ``scripts/formation/base.py`` if
patchright is not installed.
State formation portals that sit behind Incapsula/Akamai (Nevada, Delaware,
etc.) should also use this helper see ``docs/state-automation-status.md``
for the list.
Optional residential proxy support: set ``UNDETECTED_PROXY_URL`` in the
environment (e.g. ``http://user:pass@proxy.example.com:8080``) and pass
``use_proxy=True`` when launching.
"""
from __future__ import annotations
import logging
import os
import random
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, AsyncIterator
if TYPE_CHECKING:
from playwright.async_api import Browser, BrowserContext, Page, Playwright
logger = logging.getLogger(__name__)
# Prefer patchright; fall back to playwright with manual stealth patches.
_USING_PATCHRIGHT = False
try:
from patchright.async_api import async_playwright # type: ignore
_USING_PATCHRIGHT = True
logger.info("undetected_browser: using patchright")
except ImportError:
from playwright.async_api import async_playwright # type: ignore
logger.warning(
"undetected_browser: patchright not installed — falling back to "
"vanilla playwright with home-grown stealth patches. Install with "
"`pip install patchright` and run `patchright install chromium` "
"for best results against bot-detection-heavy portals."
)
# Common modern Chrome UAs. We rotate between a handful so that a burst of
# concurrent submissions doesn't all look like the same client.
_USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
]
# Common viewports; mildly jittered per-launch to vary fingerprint.
_VIEWPORTS = [
{"width": 1280, "height": 900},
{"width": 1440, "height": 900},
{"width": 1536, "height": 864},
{"width": 1920, "height": 1080},
]
# Init script run on every page — only used on the vanilla-playwright path;
# patchright handles all of these patches (and many more) internally.
_STEALTH_INIT_SCRIPT = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5].map(() => ({ name: 'Chrome PDF Plugin' })),
});
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
window.chrome = { runtime: {} };
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
if (originalQuery) {
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
}
"""
def _proxy_config() -> dict | None:
"""Read UNDETECTED_PROXY_URL and turn it into a Playwright proxy dict."""
url = os.environ.get("UNDETECTED_PROXY_URL", "").strip()
if not url:
return None
# Playwright's proxy dict supports: server, username, password, bypass
from urllib.parse import urlparse
parsed = urlparse(url)
server = f"{parsed.scheme}://{parsed.hostname}"
if parsed.port:
server += f":{parsed.port}"
cfg: dict = {"server": server}
if parsed.username:
cfg["username"] = parsed.username
if parsed.password:
cfg["password"] = parsed.password
return cfg
async def launch_context(
playwright: "Playwright",
*,
headless: bool = True,
use_proxy: bool = False,
timezone_id: str = "America/New_York",
locale: str = "en-US",
storage_state: str | None = None,
) -> "tuple[Browser, BrowserContext]":
"""Launch a Chromium browser + context with stealth settings.
Returns ``(browser, context)`` caller is responsible for closing both
(prefer the :func:`undetected_browser` context manager instead).
"""
launch_args = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
]
# On the vanilla-playwright path we add the extra flag that hides the
# AutomationControlled fingerprint. Patchright already does this (and
# adding the flag with patchright is harmless but redundant).
if not _USING_PATCHRIGHT:
launch_args.append("--disable-blink-features=AutomationControlled")
browser = await playwright.chromium.launch(
headless=headless,
args=launch_args,
)
context_kwargs: dict = {
"viewport": random.choice(_VIEWPORTS),
"user_agent": random.choice(_USER_AGENTS),
"locale": locale,
"timezone_id": timezone_id,
"java_script_enabled": True,
}
if use_proxy:
proxy = _proxy_config()
if proxy:
context_kwargs["proxy"] = proxy
else:
logger.warning(
"undetected_browser: use_proxy=True but UNDETECTED_PROXY_URL is unset"
)
if storage_state:
context_kwargs["storage_state"] = storage_state
context = await browser.new_context(**context_kwargs)
if not _USING_PATCHRIGHT:
await context.add_init_script(_STEALTH_INIT_SCRIPT)
return browser, context
@asynccontextmanager
async def undetected_browser(
*,
headless: bool = True,
use_proxy: bool = False,
timezone_id: str = "America/New_York",
locale: str = "en-US",
storage_state: str | None = None,
) -> AsyncIterator["tuple[BrowserContext, Page]"]:
"""Async context manager yielding a (context, page) pair.
Example::
async with undetected_browser(headless=False) as (ctx, page):
await page.goto("https://apps.fcc.gov/rmd/")
...
"""
async with async_playwright() as pw:
browser, context = await launch_context(
pw,
headless=headless,
use_proxy=use_proxy,
timezone_id=timezone_id,
locale=locale,
storage_state=storage_state,
)
try:
page = await context.new_page()
yield context, page
finally:
await context.close()
await browser.close()
# ─── Human-like interaction helpers (lifted from scripts/formation/base.py) ──
async def human_delay(min_s: float = 1.0, max_s: float = 3.0) -> None:
"""Random delay to appear human. Mirrors the formation base helper."""
import asyncio
await asyncio.sleep(random.uniform(min_s, max_s))
async def type_slowly(page: "Page", selector: str, text: str, delay_ms: int = 50) -> None:
"""Type text character-by-character with jitter."""
await page.click(selector)
for char in text:
await page.type(selector, char, delay=delay_ms + random.randint(0, 30))
def is_using_patchright() -> bool:
"""Return True if patchright is the active backend."""
return _USING_PATCHRIGHT