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:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
124
scripts/workers/services/__init__.py
Normal file
124
scripts/workers/services/__init__.py
Normal 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"]
|
||||
443
scripts/workers/services/base_handler.py
Normal file
443
scripts/workers/services/base_handler.py
Normal 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)
|
||||
345
scripts/workers/services/bdc_filing.py
Normal file
345
scripts/workers/services/bdc_filing.py
Normal 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)
|
||||
72
scripts/workers/services/breach_response.py
Normal file
72
scripts/workers/services/breach_response.py
Normal 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]
|
||||
370
scripts/workers/services/calea_ssi.py
Normal file
370
scripts/workers/services/calea_ssi.py
Normal 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)
|
||||
133
scripts/workers/services/campaign_review.py
Normal file
133
scripts/workers/services/campaign_review.py
Normal 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]
|
||||
2372
scripts/workers/services/canada_crtc.py
Normal file
2372
scripts/workers/services/canada_crtc.py
Normal file
File diff suppressed because it is too large
Load diff
132
scripts/workers/services/ccpa_audit.py
Normal file
132
scripts/workers/services/ccpa_audit.py
Normal 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]
|
||||
330
scripts/workers/services/cdr_analysis.py
Normal file
330
scripts/workers/services/cdr_analysis.py
Normal 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()
|
||||
134
scripts/workers/services/cdr_storage_tier.py
Normal file
134
scripts/workers/services/cdr_storage_tier.py
Normal 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"
|
||||
119
scripts/workers/services/consent_audit.py
Normal file
119
scripts/workers/services/consent_audit.py
Normal 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]
|
||||
105
scripts/workers/services/contractor_review.py
Normal file
105
scripts/workers/services/contractor_review.py
Normal 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]
|
||||
330
scripts/workers/services/cores_frn_registration.py
Normal file
330
scripts/workers/services/cores_frn_registration.py
Normal 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)
|
||||
404
scripts/workers/services/cpni_certification.py
Normal file
404
scripts/workers/services/cpni_certification.py
Normal 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)
|
||||
210
scripts/workers/services/dc_agent.py
Normal file
210
scripts/workers/services/dc_agent.py
Normal 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)
|
||||
123
scripts/workers/services/dnc_review.py
Normal file
123
scripts/workers/services/dnc_review.py
Normal 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]
|
||||
586
scripts/workers/services/fcc_compliance_checkup.py
Normal file
586
scripts/workers/services/fcc_compliance_checkup.py
Normal 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,
|
||||
}
|
||||
88
scripts/workers/services/fcc_full_compliance.py
Normal file
88
scripts/workers/services/fcc_full_compliance.py
Normal 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)
|
||||
298
scripts/workers/services/flowroute.py
Normal file
298
scripts/workers/services/flowroute.py
Normal 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 SIP→PSTN 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
|
||||
126
scripts/workers/services/flsa_audit.py
Normal file
126
scripts/workers/services/flsa_audit.py
Normal 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]
|
||||
256
scripts/workers/services/foreign_carrier_affiliation.py
Normal file
256
scripts/workers/services/foreign_carrier_affiliation.py
Normal 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)
|
||||
329
scripts/workers/services/foreign_qualification.py
Normal file
329
scripts/workers/services/foreign_qualification.py
Normal 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)
|
||||
320
scripts/workers/services/form_499_initial.py
Normal file
320
scripts/workers/services/form_499_initial.py
Normal 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)
|
||||
1430
scripts/workers/services/form_499a.py
Normal file
1430
scripts/workers/services/form_499a.py
Normal file
File diff suppressed because it is too large
Load diff
130
scripts/workers/services/handbook_review.py
Normal file
130
scripts/workers/services/handbook_review.py
Normal 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]
|
||||
133
scripts/workers/services/new_carrier_bundle.py
Normal file
133
scripts/workers/services/new_carrier_bundle.py
Normal 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)
|
||||
126
scripts/workers/services/ocn_registration.py
Normal file
126
scripts/workers/services/ocn_registration.py
Normal 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)
|
||||
260
scripts/workers/services/porkbun.py
Normal file
260
scripts/workers/services/porkbun.py
Normal 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 ""
|
||||
66
scripts/workers/services/privacy_policy.py
Normal file
66
scripts/workers/services/privacy_policy.py
Normal 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]
|
||||
741
scripts/workers/services/renewal_handler.py
Normal file
741
scripts/workers/services/renewal_handler.py
Normal 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()
|
||||
485
scripts/workers/services/rmd_filing.py
Normal file
485
scripts/workers/services/rmd_filing.py
Normal 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)
|
||||
221
scripts/workers/services/state_puc_filing.py
Normal file
221
scripts/workers/services/state_puc_filing.py
Normal 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)
|
||||
115
scripts/workers/services/stir_shaken.py
Normal file
115
scripts/workers/services/stir_shaken.py
Normal 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)
|
||||
1
scripts/workers/services/telecom/__init__.py
Normal file
1
scripts/workers/services/telecom/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Telecom compliance service helpers (undetected browser, FCC/USAC adapters)."""
|
||||
307
scripts/workers/services/telecom/auto_filing.py
Normal file
307
scripts/workers/services/telecom/auto_filing.py
Normal 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 & 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
|
||||
333
scripts/workers/services/telecom/fcc_499_utils.py
Normal file
333
scripts/workers/services/telecom/fcc_499_utils.py
Normal 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
|
||||
179
scripts/workers/services/telecom/filing_state.py
Normal file
179
scripts/workers/services/telecom/filing_state.py
Normal 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(),
|
||||
})
|
||||
221
scripts/workers/services/telecom/undetected_browser.py
Normal file
221
scripts/workers/services/telecom/undetected_browser.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue