Initial commit — Performance West telecom compliance platform

Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

31
scripts/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM python:3.12-slim
# Install LibreOffice for DOCX→PDF, Playwright deps, and system packages
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice-writer \
fonts-liberation \
fonts-dejavu \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies
COPY scripts/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium && playwright install-deps chromium
# Copy all scripts
COPY scripts/ /app/scripts/
COPY docs/product-facts.md /app/docs/product-facts.md
# Create data directories
RUN mkdir -p /app/data/screenshots /app/data/documents /app/data/logs
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
EXPOSE 8090
CMD ["python", "-m", "scripts.workers.job_server"]

140
scripts/alert.py Normal file
View file

@ -0,0 +1,140 @@
"""
alert.py Shared alerting module for Performance West monitor scripts.
Creates an ERPNext Issue when a posting account is broken or automation fails.
Import and call: alert_account_broken(monitor, platform, error)
"""
import json
import urllib.request
import urllib.parse
import os
from pathlib import Path
# ERPNext config — reads from env
ERPNEXT_URL = os.environ.get("ERPNEXT_URL", "http://erpnext:8080")
ERPNEXT_API_KEY = os.environ.get("ERPNEXT_API_KEY", "")
ERPNEXT_API_SECRET = os.environ.get("ERPNEXT_API_SECRET", "")
# Fallback: use Express API to create issues if ERPNext is unreachable
API_URL = os.environ.get("PW_API_URL", "http://api:3001")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
# Only create one alert per monitor+platform per day
_ALERT_STATE_FILE = Path.home() / ".monitor-alert-state.json"
def _load_alert_state():
if _ALERT_STATE_FILE.exists():
try:
return json.loads(_ALERT_STATE_FILE.read_text())
except Exception:
pass
return {}
def _save_alert_state(state):
_ALERT_STATE_FILE.write_text(json.dumps(state, indent=2))
def alert_account_broken(monitor: str, platform: str, error: str, detail: str = ""):
"""
Create an ERPNext Issue alerting that a posting account is broken.
Args:
monitor: Script name e.g. "reddit-monitor", "formation-worker"
platform: Platform name e.g. "Reddit", "Wyoming SOS Portal"
error: Short error description
detail: Optional longer detail / stack trace
"""
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
key = f"{monitor}:{platform}:{today}"
state = _load_alert_state()
if state.get(key):
return
subject = f"[Monitor Alert] {platform}{monitor}: {error[:80]}"
description = (
f"The **{monitor}** script detected a failure on **{platform}**.\n\n"
f"**Error:** {error}\n\n"
f"**Detail:**\n```\n{detail or 'No additional detail'}\n```\n\n"
f"**Action required:** Check the account credentials / API key for {platform} "
f"and update the configuration.\n\n"
f"**Date:** {today}"
)
issue_name = None
# Try ERPNext first
if ERPNEXT_API_KEY and ERPNEXT_API_SECRET:
issue_name = _create_erpnext_issue(subject, description)
# Fallback: Express API internal endpoint
if not issue_name:
issue_name = _create_api_issue(subject, description)
if issue_name:
print(f"[alert] ERPNext Issue '{issue_name}' created for {platform} failure")
state[key] = {"issue_name": issue_name, "error": error}
_save_alert_state(state)
else:
print(f"[alert] Failed to create alert for {platform} failure: {error}")
def _create_erpnext_issue(subject: str, description: str) -> str | None:
"""Create an Issue in ERPNext directly via REST API."""
payload = json.dumps({
"data": json.dumps({
"doctype": "Issue",
"subject": subject,
"description": description,
"issue_type": "Bug",
"priority": "High",
})
}).encode()
req = urllib.request.Request(
f"{ERPNEXT_URL}/api/resource/Issue",
data=payload,
headers={
"Authorization": f"token {ERPNEXT_API_KEY}:{ERPNEXT_API_SECRET}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
resp = json.loads(r.read())
return resp.get("data", {}).get("name", "")
except Exception as e:
print(f"[alert] ERPNext Issue creation failed: {e}")
return None
def _create_api_issue(subject: str, description: str) -> str | None:
"""Fallback: create a ticket via our Express API."""
payload = json.dumps({
"category": "issue",
"subject": subject,
"message": description,
"email": "alerts@performancewest.net",
"name": "System Monitor",
}).encode()
req = urllib.request.Request(
f"{API_URL}/api/v1/tickets",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
resp = json.loads(r.read())
return resp.get("ticket_id", "")
except Exception as e:
print(f"[alert] Express API ticket creation failed: {e}")
return None

54
scripts/backup-db.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# backup-db.sh — Daily PostgreSQL backup to MinIO
#
# Run on the production server via cron:
# 0 3 * * * /opt/performancewest/scripts/backup-db.sh >> /var/log/pw-backup.log 2>&1
#
# Backs up:
# - api-postgres (performancewest DB) — orders, fees, sessions
# - umami-postgres (umami DB) — analytics
#
# Retention: 30 days (older backups deleted automatically)
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BUCKET="${MINIO_BUCKET:-performancewest}"
MINIO_ALIAS="pwminio"
# ── Setup mc alias ────────────────────────────────────────────────────────────
mc alias set "${MINIO_ALIAS}" \
"http://minio:9000" \
"${MINIO_ACCESS_KEY:-performancewest}" \
"${MINIO_SECRET_KEY:-changeme}" \
--quiet 2>/dev/null || true
# ── Helper ────────────────────────────────────────────────────────────────────
backup_db() {
local container="$1"
local db_name="$2"
local pg_user="$3"
local label="$4"
local filename="${label}_${TIMESTAMP}.sql.gz"
local minio_path="${MINIO_ALIAS}/${BUCKET}/backups/${filename}"
echo "[$(date -u +%H:%M:%S)] Backing up ${label}${minio_path}"
docker exec "${container}" \
pg_dump -U "${pg_user}" "${db_name}" \
| gzip \
| mc pipe "${minio_path}"
echo "[$(date -u +%H:%M:%S)] ${label} backup complete: ${filename}"
}
# ── Run backups ───────────────────────────────────────────────────────────────
backup_db "api-postgres" "performancewest" "pw" "performancewest"
backup_db "umami-postgres" "umami" "umami" "umami"
# ── Prune backups older than 30 days ─────────────────────────────────────────
echo "[$(date -u +%H:%M:%S)] Pruning backups older than 30 days..."
mc rm --recursive --force --older-than 30d \
"${MINIO_ALIAS}/${BUCKET}/backups/" 2>/dev/null || true
echo "[$(date -u +%H:%M:%S)] Backup run complete."

56
scripts/deploy-dev.sh Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Deploy to the dev site at dev.performancewest.net.
# Usage: bash scripts/deploy-dev.sh
#
# What it does:
# 1. Syncs API source + scripts + site to the dev server
# 2. Rebuilds dev API + site Docker containers
# 3. No manual docker-cp or _astro merging needed — everything
# is in site/public/ and gets built into the image.
set -euo pipefail
REMOTE="deploy@207.174.124.71"
SSH="ssh -p 22022"
SCP="scp -P 22022"
DEV_DIR="/opt/performancewest-dev"
RSYNC_OPTS="-avz --delete --exclude=node_modules --exclude=.git --exclude='__pycache__' --exclude=dist --exclude=.env --exclude='site-old'"
echo "=== Syncing source files ==="
rsync $RSYNC_OPTS \
-e "$SSH" \
api/src/ "$REMOTE:$DEV_DIR/api/src/"
rsync $RSYNC_OPTS \
-e "$SSH" \
api/migrations/ "$REMOTE:$DEV_DIR/api/migrations/"
rsync $RSYNC_OPTS \
-e "$SSH" \
scripts/ "$REMOTE:$DEV_DIR/scripts/"
rsync $RSYNC_OPTS \
-e "$SSH" \
site/src/ "$REMOTE:$DEV_DIR/site/src/"
rsync $RSYNC_OPTS \
-e "$SSH" \
site/public/ "$REMOTE:$DEV_DIR/site/public/"
# Also sync config files that live at the site root
for f in site/Dockerfile site/nginx.conf site/package.json site/astro.config.mjs site/tsconfig.json; do
if [ -f "$f" ]; then
rsync -avz -e "$SSH" "$f" "$REMOTE:$DEV_DIR/$f"
fi
done
echo ""
echo "=== Rebuilding containers ==="
$SSH "$REMOTE" "cd $DEV_DIR && sudo docker compose up -d --build api site workers"
echo ""
echo "=== Deploy complete ==="
echo "Dev site: https://dev.performancewest.net"
echo "Dev API: https://api.dev.performancewest.net"

233
scripts/deploy-go-live.sh Executable file
View file

@ -0,0 +1,233 @@
#!/usr/bin/env bash
# deploy-go-live.sh — Run pending migrations, deploy crons, populate entity cache,
# create ERPNext item, and verify Playwright selectors.
#
# Usage:
# ssh deploy@207.174.124.71 -p 22022
# cd /opt/performancewest
# bash scripts/deploy-go-live.sh
#
# Or from a machine with SSH access:
# scp scripts/deploy-go-live.sh deploy@207.174.124.71:/opt/performancewest/scripts/
# ssh -p 22022 deploy@207.174.124.71 'cd /opt/performancewest && bash scripts/deploy-go-live.sh'
set -euo pipefail
PROJECT_DIR="${PROJECT_DIR:-/opt/performancewest}"
cd "$PROJECT_DIR"
echo "=========================================="
echo " Performance West — Go-Live Deployment"
echo "=========================================="
echo ""
# ─────────────────────────────────────────────────
# #1: Run pending DB migrations (069-073)
# ─────────────────────────────────────────────────
echo ">>> Step 1: Running pending database migrations..."
# Source DATABASE_URL from the API container's env
DB_URL=$(docker compose exec -T api printenv DATABASE_URL 2>/dev/null || echo "")
if [ -z "$DB_URL" ]; then
echo "ERROR: Cannot read DATABASE_URL from api container. Is it running?"
echo "Try: docker compose up -d api"
exit 1
fi
# Check which migrations are already applied
echo " Checking existing tables..."
HAS_AUDIT=$(docker compose exec -T api node -e "
const {Pool} = require('pg');
const p = new Pool({connectionString: process.env.DATABASE_URL});
p.query(\"SELECT 1 FROM pg_tables WHERE tablename='fcc_rmd_audit_results'\")
.then(r => { console.log(r.rows.length > 0 ? 'yes' : 'no'); p.end(); })
.catch(() => { console.log('no'); p.end(); });
" 2>/dev/null || echo "no")
HAS_PUC=$(docker compose exec -T api node -e "
const {Pool} = require('pg');
const p = new Pool({connectionString: process.env.DATABASE_URL});
p.query(\"SELECT 1 FROM pg_tables WHERE tablename='state_puc_requirements'\")
.then(r => { console.log(r.rows.length > 0 ? 'yes' : 'no'); p.end(); })
.catch(() => { console.log('no'); p.end(); });
" 2>/dev/null || echo "no")
HAS_RMD_REVIEW=$(docker compose exec -T api node -e "
const {Pool} = require('pg');
const p = new Pool({connectionString: process.env.DATABASE_URL});
p.query(\"SELECT 1 FROM information_schema.columns WHERE table_name='compliance_orders' AND column_name='rmd_review_status'\")
.then(r => { console.log(r.rows.length > 0 ? 'yes' : 'no'); p.end(); })
.catch(() => { console.log('no'); p.end(); });
" 2>/dev/null || echo "no")
# Run migrations that haven't been applied
MIGRATIONS_DIR="api/migrations"
if [ "$HAS_AUDIT" = "no" ]; then
echo " Applying 070_rmd_audit_results.sql..."
docker compose exec -T api sh -c "psql \$DATABASE_URL < /app/$MIGRATIONS_DIR/070_rmd_audit_results.sql"
echo " ✓ 070 applied"
else
echo " ✓ 070 already applied (fcc_rmd_audit_results exists)"
fi
if [ "$HAS_RMD_REVIEW" = "no" ]; then
echo " Applying 071_rmd_review_columns.sql..."
docker compose exec -T api sh -c "psql \$DATABASE_URL < /app/$MIGRATIONS_DIR/071_rmd_review_columns.sql"
echo " ✓ 071 applied"
else
echo " ✓ 071 already applied (rmd_review_status column exists)"
fi
if [ "$HAS_PUC" = "no" ]; then
echo " Applying 072_state_puc_requirements.sql..."
docker compose exec -T api sh -c "psql \$DATABASE_URL < /app/$MIGRATIONS_DIR/072_state_puc_requirements.sql"
echo " ✓ 072 applied"
echo " Applying 073_state_puc_registrations.sql..."
docker compose exec -T api sh -c "psql \$DATABASE_URL < /app/$MIGRATIONS_DIR/073_state_puc_registrations.sql"
echo " ✓ 073 applied"
else
echo " ✓ 072-073 already applied (state_puc_requirements exists)"
fi
# Verify
PUC_COUNT=$(docker compose exec -T api node -e "
const {Pool} = require('pg');
const p = new Pool({connectionString: process.env.DATABASE_URL});
p.query('SELECT COUNT(*) as c FROM state_puc_requirements')
.then(r => { console.log(r.rows[0].c); p.end(); })
.catch(e => { console.log('ERROR: ' + e.message); p.end(); });
" 2>/dev/null || echo "ERROR")
echo " State PUC requirements: $PUC_COUNT rows"
echo ""
# ─────────────────────────────────────────────────
# #2: Deploy cron jobs via Ansible
# ─────────────────────────────────────────────────
echo ">>> Step 2: Deploying systemd cron timers..."
if command -v ansible-playbook &>/dev/null; then
cd "$PROJECT_DIR/infra/ansible"
ansible-playbook playbooks/deploy-crons.yml -i inventory/hosts.yml --connection=local 2>&1 | tail -20
cd "$PROJECT_DIR"
echo " ✓ Cron timers deployed"
else
echo " SKIP: ansible-playbook not found. Install ansible or run manually:"
echo " cd infra/ansible && ansible-playbook playbooks/deploy-crons.yml -i inventory/hosts.yml --connection=local"
fi
echo ""
# ─────────────────────────────────────────────────
# #3: Verify Playwright selectors (dry-run)
# ─────────────────────────────────────────────────
echo ">>> Step 3: Playwright selector verification (smoke test)..."
echo " The RMD filing handler uses these selectors against the FCC RMD portal:"
echo " - text=Certification"
echo " - text=File Certification"
echo " - input[name='frn']"
echo " - input[name='company_legal_name']"
echo " - input[name='stir_shaken_status']"
echo " - button[type='submit']"
echo " - text=Confirmation Number"
echo ""
echo " These are generic ServiceNow patterns. The handler has an admin-todo"
echo " fallback if any selector fails — no orders will be lost."
echo ""
echo " To verify live: run a test filing (non-production FRN) through the RMD"
echo " handler and check if selectors resolve."
echo ""
echo " Running Playwright connectivity check..."
docker compose exec -T workers python3 -c "
try:
from patchright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto('https://fccprod.servicenowservices.com/rmd', timeout=30000)
title = page.title()
print(f' FCC RMD portal reachable: {title}')
# Check if key elements exist on the landing page
cert_link = page.locator('text=Certification').first
if cert_link:
print(' ✓ \"Certification\" text found on page')
browser.close()
except Exception as e:
print(f' WARNING: Could not reach FCC RMD portal: {e}')
print(' This is expected if running from a restricted network.')
print(' The handler will fall back to admin-todo if selectors fail.')
" 2>&1 || echo " (Playwright not available in workers container — verify manually)"
echo ""
# ─────────────────────────────────────────────────
# #4: Populate entity cache
# ─────────────────────────────────────────────────
echo ">>> Step 4: Populating entity cache (Socrata bulk download)..."
echo " Running bulk download for all configured states (CO, NY, CT, OR, IA)..."
docker compose exec -T workers python3 -m scripts.formation.bulk_download --all 2>&1 | tail -20
echo ""
echo " Running Florida SFTP download..."
docker compose exec -T workers python3 -m scripts.workers.fl_entity_downloader --daily 2>&1 | tail -10
echo ""
# Check totals
docker compose exec -T api node -e "
const {Pool} = require('pg');
const p = new Pool({connectionString: process.env.DATABASE_URL});
p.query('SELECT state, COUNT(*) as c FROM entity_cache GROUP BY state ORDER BY c DESC')
.then(r => {
let total = 0;
for (const row of r.rows) {
console.log(' ' + row.state + ': ' + Number(row.c).toLocaleString() + ' entities');
total += Number(row.c);
}
console.log(' TOTAL: ' + total.toLocaleString() + ' entities');
p.end();
})
.catch(e => { console.log(' Entity cache query failed: ' + e.message); p.end(); });
" 2>/dev/null
echo ""
# ─────────────────────────────────────────────────
# #5: Create ERPNext Item for State PUC
# ─────────────────────────────────────────────────
echo ">>> Step 5: Creating ERPNext Item for State PUC..."
docker compose exec -T workers python3 -c "
from scripts.workers.erpnext_client import ERPNextClient
client = ERPNextClient()
# Check if STATE-PUC item already exists
try:
existing = client.get_resource('Item', 'STATE-PUC')
print(' ✓ STATE-PUC item already exists')
except Exception:
# Create it
try:
client.create_resource('Item', {
'item_code': 'STATE-PUC',
'item_name': 'State PUC/PSC Registration',
'item_group': 'Services',
'stock_uom': 'Nos',
'is_stock_item': 0,
'is_sales_item': 1,
'description': 'State PUC/PSC registration for VoIP, broadband, or CLEC providers. Per-state service fee.',
'standard_rate': 399.00,
'item_defaults': [{'company': 'Performance West Inc', 'income_account': 'Sales - PWI', 'default_warehouse': ''}],
})
print(' ✓ STATE-PUC item created in ERPNext')
except Exception as e:
print(f' WARNING: Could not create STATE-PUC item: {e}')
print(' Create it manually in ERPNext: Item Code=STATE-PUC, Rate=\$399, Group=Services')
" 2>&1
echo ""
echo "=========================================="
echo " Go-Live Deployment Complete"
echo "=========================================="
echo ""
echo "Remaining manual steps:"
echo " 1. Verify one test RMD filing through the Playwright handler"
echo " 2. Approve Listmonk campaign when ready (run rmd_deficiency_campaign.py)"
echo " 3. Set up California BizFileOnline weekly subscription for entity cache"
echo ""

97
scripts/deploy.sh Executable file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
# deploy.sh — Deploy Performance West to production
#
# Usage:
# ./scripts/deploy.sh # full deploy (rsync + build + restart)
# ./scripts/deploy.sh --rsync # rsync only
# ./scripts/deploy.sh --build # build only (run on server)
#
# Prerequisites:
# - SSH key configured for deploy@207.174.124.71 port 22022
# - .env configured on server at /opt/performancewest/.env
set -euo pipefail
SERVER="deploy@207.174.124.71"
SSH_PORT=22022
REMOTE_DIR="/opt/performancewest"
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Colors
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[deploy]${NC} $*"; }
warn() { echo -e "${YELLOW}[deploy]${NC} $*"; }
die() { echo -e "${RED}[deploy] ERROR:${NC} $*" >&2; exit 1; }
# ── Parse args ────────────────────────────────────────────────────────────────
DO_RSYNC=true
DO_BUILD=true
for arg in "$@"; do
case "$arg" in
--rsync) DO_RSYNC=true; DO_BUILD=false ;;
--build) DO_RSYNC=false; DO_BUILD=true ;;
esac
done
# ── 1. Rsync ──────────────────────────────────────────────────────────────────
if $DO_RSYNC; then
log "Syncing to ${SERVER}:${REMOTE_DIR} ..."
rsync \
--archive \
--compress \
--delete \
--timeout=30 \
--exclude='.git' \
--exclude='node_modules' \
--exclude='site/.astro' \
--exclude='site/dist' \
--exclude='api/dist' \
--exclude='api/node_modules' \
--exclude='mcp/node_modules' \
--exclude='mcp/dist' \
--exclude='**/__pycache__' \
--exclude='*.pyc' \
--exclude='.env' \
--exclude='*.log' \
-e "ssh -p ${SSH_PORT}" \
"${LOCAL_DIR}/" \
"${SERVER}:${REMOTE_DIR}/"
log "Rsync complete."
fi
# ── 2. Remote build + restart ─────────────────────────────────────────────────
if $DO_BUILD; then
log "Building + restarting on server ..."
ssh -p "${SSH_PORT}" "${SERVER}" bash <<'REMOTE'
set -euo pipefail
cd /opt/performancewest
echo "[remote] Building Docker images..."
docker compose build --parallel
echo "[remote] Running DB migrations..."
docker compose run --rm api node -e "
const { pool } = require('./dist/db.js');
pool.end().then(() => console.log('DB connection OK'));
" 2>/dev/null || true
echo "[remote] Restarting containers..."
docker compose up -d --remove-orphans
echo "[remote] Waiting for API health check..."
for i in $(seq 1 20); do
if curl -sf http://localhost:3001/health > /dev/null 2>&1; then
echo "[remote] API is healthy."
break
fi
sleep 3
done
echo "[remote] Container status:"
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
REMOTE
log "Deploy complete."
fi
log "Done. Site: https://performancewest.net | API: https://api.performancewest.net"

View file

@ -0,0 +1,5 @@
# Document generation library — DOCX templates, PDF conversion, MinIO storage, LLM writing
from .docx_builder import DocxBuilder
from .pdf_converter import convert_to_pdf
from .minio_client import MinioStorage
from .llm_writer import LLMWriter

View file

@ -0,0 +1,222 @@
"""
DOCX template builder using python-docx + Jinja2.
Templates use Jinja2 placeholders: {{ variable_name }}
Supports:
- Simple variable substitution
- Conditional sections ({% if ... %})
- Loops for member tables ({% for member in members %})
- Section insertion (replace a placeholder paragraph with multi-paragraph LLM output)
"""
from __future__ import annotations
import copy
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from jinja2 import Template
LOG = logging.getLogger("document_gen.docx")
TEMPLATES_DIR = Path(os.getenv("TEMPLATES_DIR", "/app/scripts/templates"))
class DocxBuilder:
"""Build DOCX documents from templates with variable substitution."""
def __init__(self, template_name: str):
"""Load a DOCX template by name (e.g., 'operating-agreement')."""
self.template_path = TEMPLATES_DIR / f"{template_name}.docx"
if not self.template_path.exists():
raise FileNotFoundError(f"Template not found: {self.template_path}")
self.doc = Document(str(self.template_path))
self.variables: dict[str, Any] = {}
def set_variables(self, variables: dict[str, Any]) -> "DocxBuilder":
"""Set template variables for substitution."""
self.variables = variables
return self
def fill(self) -> "DocxBuilder":
"""Fill all Jinja2 placeholders in the document."""
# Process paragraphs
for para in self.doc.paragraphs:
self._fill_paragraph(para)
# Process table cells
for table in self.doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
self._fill_paragraph(para)
# Process headers and footers
for section in self.doc.sections:
for header_para in section.header.paragraphs:
self._fill_paragraph(header_para)
for footer_para in section.footer.paragraphs:
self._fill_paragraph(footer_para)
return self
def _fill_paragraph(self, para):
"""Replace Jinja2 placeholders in a paragraph, preserving formatting."""
full_text = para.text
if "{{" not in full_text and "{%" not in full_text:
return
# Render the full paragraph text through Jinja2
try:
template = Template(full_text)
rendered = template.render(**self.variables)
except Exception as e:
LOG.warning("Template render error in paragraph: %s%s", full_text[:80], e)
return
if rendered == full_text:
return
# Clear all runs and set the rendered text in the first run
if para.runs:
# Preserve the formatting of the first run
first_run = para.runs[0]
first_run.text = rendered
for run in para.runs[1:]:
run.text = ""
else:
para.text = rendered
def insert_section(self, placeholder: str, content: str) -> "DocxBuilder":
"""Replace a placeholder paragraph with multi-paragraph content.
Used for LLM-generated sections the placeholder (e.g., '{{findings_section}}')
is replaced with multiple paragraphs of formatted text.
"""
for i, para in enumerate(self.doc.paragraphs):
if placeholder in para.text:
# Split content into paragraphs
lines = content.strip().split("\n\n")
# Replace the placeholder paragraph with the first line
para.text = lines[0] if lines else ""
# Insert remaining lines as new paragraphs after the current one
for j, line in enumerate(lines[1:], 1):
new_para = copy.deepcopy(para)
new_para.text = line
para._element.addnext(new_para._element)
return self
LOG.warning("Placeholder not found: %s", placeholder)
return self
def add_cover_page(
self,
title: str,
subtitle: str = "",
client_name: str = "",
order_number: str = "",
date: str = "",
) -> "DocxBuilder":
"""Add a branded cover page at the beginning of the document."""
# Insert paragraphs at the top
first_para = self.doc.paragraphs[0] if self.doc.paragraphs else self.doc.add_paragraph()
# We'll prepend by inserting before the first paragraph
cover_elements = []
# Spacer
spacer = self.doc.add_paragraph()
spacer.space_after = Pt(72)
# Title
title_para = self.doc.add_paragraph()
title_run = title_para.add_run(title)
title_run.font.size = Pt(28)
title_run.font.color.rgb = RGBColor(0x2D, 0x4E, 0x78) # pw-700
title_run.font.bold = True
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Subtitle
if subtitle:
sub_para = self.doc.add_paragraph()
sub_run = sub_para.add_run(subtitle)
sub_run.font.size = Pt(14)
sub_run.font.color.rgb = RGBColor(0x6B, 0x72, 0x80)
sub_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Client info
if client_name:
client_para = self.doc.add_paragraph()
client_para.space_before = Pt(36)
client_run = client_para.add_run(f"Prepared for: {client_name}")
client_run.font.size = Pt(12)
client_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Order number + date
meta_para = self.doc.add_paragraph()
meta_parts = []
if order_number:
meta_parts.append(f"Order: {order_number}")
meta_parts.append(f"Date: {date or datetime.now().strftime('%B %d, %Y')}")
meta_run = meta_para.add_run(" | ".join(meta_parts))
meta_run.font.size = Pt(10)
meta_run.font.color.rgb = RGBColor(0x9C, 0xA3, 0xAF)
meta_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Performance West branding
brand_para = self.doc.add_paragraph()
brand_para.space_before = Pt(48)
brand_run = brand_para.add_run("Performance West Inc.")
brand_run.font.size = Pt(10)
brand_run.font.color.rgb = RGBColor(0x2D, 0x4E, 0x78)
brand_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
addr_para = self.doc.add_paragraph()
addr_run = addr_para.add_run("525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 | 1-888-411-0383")
addr_run.font.size = Pt(8)
addr_run.font.color.rgb = RGBColor(0x9C, 0xA3, 0xAF)
addr_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Page break after cover
self.doc.add_page_break()
# Move cover elements to the beginning
body = self.doc.element.body
# The paragraphs we just added are at the end — move them to the front
added = list(body)[-8:] # Last 8 elements we added (spacer, title, sub, client, meta, brand, addr, pagebreak)
for elem in reversed(added):
body.insert(0, elem)
return self
def add_disclaimer(self, text: str = "") -> "DocxBuilder":
"""Add a disclaimer paragraph at the end of the document."""
default = (
"DISCLAIMER: This document is prepared by Performance West Inc. for compliance consulting purposes only. "
"It does not constitute legal advice, legal representation, or create an attorney-client relationship. "
"For legal matters, consult a licensed attorney in your jurisdiction."
)
para = self.doc.add_paragraph()
para.space_before = Pt(24)
run = para.add_run(text or default)
run.font.size = Pt(8)
run.font.italic = True
run.font.color.rgb = RGBColor(0x9C, 0xA3, 0xAF)
return self
def save(self, output_path: str | Path) -> Path:
"""Save the filled document to a file."""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
self.doc.save(str(output_path))
LOG.info("DOCX saved: %s", output_path)
return output_path

View file

@ -0,0 +1,137 @@
"""
LLM content writer for compliance report sections.
Uses Ollama (local LLM) to generate analysis and prose for compliance reports.
Each service type provides its own system prompt and section templates.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any
import httpx
LOG = logging.getLogger("document_gen.llm")
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
DEFAULT_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:7b")
class LLMWriter:
"""Generate compliance report content using a local LLM."""
def __init__(self, model: str = DEFAULT_MODEL):
self.model = model
self.base_url = OLLAMA_HOST
self.client = httpx.Client(timeout=300.0) # 5 min timeout for long generations
def generate_section(
self,
system_prompt: str,
user_prompt: str,
temperature: float = 0.3,
max_tokens: int = 4096,
) -> str:
"""Generate a single section of a compliance report.
Args:
system_prompt: System instructions (compliance rules, format requirements)
user_prompt: The specific section to generate (includes customer data)
temperature: Lower = more factual, higher = more creative
max_tokens: Maximum output length
Returns:
Generated text content for the section
"""
LOG.info("Generating section (model=%s, temp=%.1f)...", self.model, temperature)
try:
response = self.client.post(
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
"stream": False,
},
)
response.raise_for_status()
data = response.json()
content = data.get("message", {}).get("content", "")
LOG.info("Generated %d characters", len(content))
return content.strip()
except httpx.HTTPError as e:
LOG.error("Ollama request failed: %s", e)
raise RuntimeError(f"LLM generation failed: {e}") from e
def generate_report(
self,
service_type: str,
customer_data: dict[str, Any],
sections: list[dict[str, str]],
system_prompt: str,
) -> dict[str, str]:
"""Generate all sections of a compliance report.
Args:
service_type: Service identifier (e.g., 'flsa_audit')
customer_data: Customer and order information
sections: List of {"name": "section_name", "prompt": "section-specific instructions"}
system_prompt: Base system prompt for this service type
Returns:
Dict mapping section names to generated content
"""
results: dict[str, str] = {}
customer_json = json.dumps(customer_data, indent=2)
for section in sections:
section_name = section["name"]
section_prompt = section["prompt"]
user_prompt = (
f"SERVICE: {service_type}\n"
f"SECTION: {section_name}\n\n"
f"CUSTOMER DATA:\n{customer_json}\n\n"
f"INSTRUCTIONS:\n{section_prompt}"
)
try:
content = self.generate_section(
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=0.3,
)
results[section_name] = content
LOG.info("Section '%s' generated (%d chars)", section_name, len(content))
except Exception as e:
LOG.error("Section '%s' failed: %s", section_name, e)
results[section_name] = f"[GENERATION FAILED: {e}]"
return results
def health_check(self) -> bool:
"""Check if Ollama is reachable and the model is available."""
try:
resp = self.client.get(f"{self.base_url}/api/tags")
if resp.status_code != 200:
return False
models = resp.json().get("models", [])
model_names = [m.get("name", "") for m in models]
available = any(self.model in name for name in model_names)
if not available:
LOG.warning("Model %s not found. Available: %s", self.model, model_names)
return available
except Exception as e:
LOG.error("Ollama health check failed: %s", e)
return False

View file

@ -0,0 +1,128 @@
"""
MinIO (S3-compatible) client for document storage.
Uploads generated documents to MinIO and returns accessible URLs.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from minio import Minio
from minio.error import S3Error
LOG = logging.getLogger("document_gen.minio")
BUCKET = os.getenv("MINIO_BUCKET", "performancewest")
class MinioStorage:
"""S3-compatible document storage via MinIO."""
def __init__(self):
self.client = Minio(
endpoint=f"{os.getenv('MINIO_ENDPOINT', 'localhost')}:{os.getenv('MINIO_PORT', '9000')}",
access_key=os.getenv("MINIO_ACCESS_KEY", ""),
secret_key=os.getenv("MINIO_SECRET_KEY", ""),
secure=os.getenv("MINIO_SECURE", "false").lower() == "true",
)
self._ensure_bucket()
def _ensure_bucket(self):
"""Create the bucket if it doesn't exist."""
try:
if not self.client.bucket_exists(BUCKET):
self.client.make_bucket(BUCKET)
LOG.info("Created MinIO bucket: %s", BUCKET)
except S3Error as e:
LOG.error("MinIO bucket check failed: %s", e)
def upload(
self,
local_path: str | Path,
remote_path: str,
content_type: str = "application/octet-stream",
) -> str:
"""Upload a file to MinIO.
Args:
local_path: Local file path
remote_path: Object key in the bucket (e.g., "formations/PW-2026-XXXX/articles.pdf")
content_type: MIME type
Returns:
The object URL (internal MinIO URL)
"""
local_path = Path(local_path)
if not local_path.exists():
raise FileNotFoundError(f"File not found: {local_path}")
# Auto-detect content type
if content_type == "application/octet-stream":
suffix = local_path.suffix.lower()
content_types = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc": "application/msword",
".html": "text/html",
".txt": "text/plain",
".png": "image/png",
".jpg": "image/jpeg",
}
content_type = content_types.get(suffix, content_type)
try:
self.client.fput_object(
BUCKET,
remote_path,
str(local_path),
content_type=content_type,
)
LOG.info("Uploaded: %s%s/%s", local_path.name, BUCKET, remote_path)
return f"{BUCKET}/{remote_path}"
except S3Error as e:
LOG.error("MinIO upload failed: %s", e)
raise
def download(self, remote_path: str, local_path: str | Path) -> Path:
"""Download a file from MinIO."""
local_path = Path(local_path)
local_path.parent.mkdir(parents=True, exist_ok=True)
try:
self.client.fget_object(BUCKET, remote_path, str(local_path))
LOG.info("Downloaded: %s/%s%s", BUCKET, remote_path, local_path)
return local_path
except S3Error as e:
LOG.error("MinIO download failed: %s", e)
raise
def get_url(self, remote_path: str, expires_hours: int = 24) -> str:
"""Get a presigned URL for a file (for client download)."""
from datetime import timedelta
try:
url = self.client.presigned_get_object(
BUCKET, remote_path, expires=timedelta(hours=expires_hours),
)
return url
except S3Error as e:
LOG.error("MinIO presign failed: %s", e)
raise
def list_objects(self, prefix: str) -> list[str]:
"""List all objects under a prefix."""
try:
objects = self.client.list_objects(BUCKET, prefix=prefix, recursive=True)
return [obj.object_name for obj in objects]
except S3Error as e:
LOG.error("MinIO list failed: %s", e)
return []
def delete(self, remote_path: str):
"""Delete an object from MinIO."""
try:
self.client.remove_object(BUCKET, remote_path)
LOG.info("Deleted: %s/%s", BUCKET, remote_path)
except S3Error as e:
LOG.error("MinIO delete failed: %s", e)

View file

@ -0,0 +1,285 @@
"""
DOCX PDF conversion.
Primary: Windows Word VM via MinIO (pixel-perfect, no open ports required).
Fallback: LibreOffice headless (70-80% fidelity, always available in container).
MinIO transport protocol
PUT docx {bucket}/to-convert/{job_id}.docx (this module)
WAIT poll {bucket}/converted/{job_id}.pdf (this module)
GET pdf {bucket}/converted/{job_id}.pdf (this module)
DEL docx {bucket}/to-convert/{job_id}.docx (docserver_worker.py)
DEL pdf {bucket}/converted/{job_id}.pdf (this module, after download)
The Windows VM runs docserver_worker.py which:
1. Polls to-convert/ every 12 seconds
2. Downloads the DOCX, converts via Word COM, uploads the PDF to converted/
3. Deletes the source DOCX from to-convert/
No HTTP server, no open ports, no SSH tunnel. Only MinIO is needed.
Environment variables (same MinIO creds as the workers):
MINIO_ENDPOINT MinIO host (default: minio)
MINIO_PORT MinIO port (default: 9000)
MINIO_ACCESS_KEY access key
MINIO_SECRET_KEY secret key
MINIO_BUCKET bucket name (default: performancewest)
USE_DOCSERVER enable Word VM path (default: true)
DOCSERVER_TIMEOUT max seconds to wait for Word to produce the PDF (default: 120)
"""
from __future__ import annotations
import io
import logging
import os
import subprocess
import time
import uuid
from pathlib import Path
LOG = logging.getLogger("document_gen.pdf")
# MinIO settings — inherited from the workers container env
_MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio")
_MINIO_PORT = int(os.getenv("MINIO_PORT", "9000"))
_MINIO_ACCESS = os.getenv("MINIO_ACCESS_KEY", "")
_MINIO_SECRET = os.getenv("MINIO_SECRET_KEY", "")
_MINIO_BUCKET = os.getenv("MINIO_BUCKET", "performancewest")
_MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
USE_DOCSERVER = os.getenv("USE_DOCSERVER", "true").lower() == "true"
DOCSERVER_TIMEOUT = int(os.getenv("DOCSERVER_TIMEOUT", "120")) # seconds
_POLL_INTERVAL = 12 # seconds between polls for the converted PDF
# MinIO key prefixes
_PREFIX_IN = "to-convert" # docx files waiting to be processed
_PREFIX_OUT = "converted" # pdf files ready for pickup
def _minio_client():
"""Return a configured MinIO client."""
from minio import Minio # type: ignore
return Minio(
f"{_MINIO_ENDPOINT}:{_MINIO_PORT}",
access_key=_MINIO_ACCESS,
secret_key=_MINIO_SECRET,
secure=_MINIO_SECURE,
)
# ── Public API ────────────────────────────────────────────────────────────────
def convert_to_pdf(docx_path: str | Path, output_dir: str | Path | None = None) -> Path:
"""Convert a DOCX to PDF.
Tries the Word VM via MinIO first (pixel-perfect).
Falls back to LibreOffice headless if the VM is unavailable or slow.
Args:
docx_path: Path to the .docx file on disk
output_dir: Where to write the PDF (defaults to same dir as docx)
Returns:
Path to the generated PDF file
"""
docx_path = Path(docx_path)
if not docx_path.exists():
raise FileNotFoundError(f"DOCX not found: {docx_path}")
out_dir = Path(output_dir) if output_dir else docx_path.parent
out_dir.mkdir(parents=True, exist_ok=True)
pdf_path = out_dir / docx_path.with_suffix(".pdf").name
if USE_DOCSERVER and _MINIO_ACCESS:
try:
return _convert_via_minio(docx_path, pdf_path)
except Exception as exc:
LOG.warning(
"Word VM via MinIO unavailable (%s) — falling back to LibreOffice", exc
)
return _convert_via_libreoffice(docx_path, pdf_path, out_dir)
def convert_batch(docx_paths: list[str | Path], output_dir: str | Path) -> list[Path]:
"""Convert multiple DOCX files to PDFs.
Submits all jobs to the Word VM concurrently (each gets its own MinIO key),
then collects results as they arrive. Falls back per-file to LibreOffice.
"""
docx_paths = [Path(p) for p in docx_paths]
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if USE_DOCSERVER and _MINIO_ACCESS and docx_paths:
try:
return _batch_via_minio(docx_paths, output_dir)
except Exception as exc:
LOG.warning("Batch via Word VM failed (%s) — converting one by one via LibreOffice", exc)
results = []
for docx_path in docx_paths:
try:
results.append(convert_to_pdf(docx_path, output_dir))
except Exception as exc:
LOG.error("Failed to convert %s: %s", docx_path.name, exc)
return results
def health_check() -> dict:
"""Return status of both conversion backends."""
status: dict = {"libreoffice": False, "docserver_minio": False}
# LibreOffice
try:
r = subprocess.run(
["libreoffice", "--version"],
capture_output=True, text=True, timeout=10,
)
status["libreoffice"] = r.returncode == 0
except Exception:
pass
# Word VM — check if the MinIO bucket is accessible and if the worker
# has recently touched a heartbeat object
if USE_DOCSERVER and _MINIO_ACCESS:
try:
mc = _minio_client()
mc.bucket_exists(_MINIO_BUCKET) # just checks connectivity
status["docserver_minio"] = True
status["minio_bucket"] = _MINIO_BUCKET
except Exception as exc:
status["minio_error"] = str(exc)
return status
# ── MinIO transport ───────────────────────────────────────────────────────────
def _convert_via_minio(docx_path: Path, pdf_path: Path) -> Path:
"""Upload DOCX to MinIO, wait for the Word VM to convert it, download PDF.
Atomic upload: the DOCX is first uploaded to a .tmp key, then renamed
(copy + delete) to the final key. This prevents the Windows worker from
downloading a partially-uploaded file.
"""
from minio.commonconfig import CopySource # type: ignore
job_id = str(uuid.uuid4()).replace("-", "")
tmp_key = f"{_PREFIX_IN}/.tmp_{job_id}.docx"
in_key = f"{_PREFIX_IN}/{job_id}.docx"
out_key = f"{_PREFIX_OUT}/{job_id}.pdf"
mc = _minio_client()
# Ensure bucket exists
if not mc.bucket_exists(_MINIO_BUCKET):
mc.make_bucket(_MINIO_BUCKET)
# Upload DOCX to temp key first (invisible to worker — it ignores .tmp_ prefix)
LOG.info("[%s] Uploading %s → minio://%s/%s (staging)", job_id[:8], docx_path.name, _MINIO_BUCKET, tmp_key)
mc.fput_object(
_MINIO_BUCKET, tmp_key, str(docx_path),
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
metadata={"x-amz-meta-source": docx_path.name},
)
# Atomic rename: copy tmp → final, then delete tmp
# MinIO copy_object is a server-side operation — the object appears
# at the destination key atomically (no partial state visible)
mc.copy_object(
_MINIO_BUCKET, in_key,
CopySource(_MINIO_BUCKET, tmp_key),
)
mc.remove_object(_MINIO_BUCKET, tmp_key)
LOG.info("[%s] Staged → minio://%s/%s (live)", job_id[:8], _MINIO_BUCKET, in_key)
# Poll for the converted PDF
deadline = time.monotonic() + DOCSERVER_TIMEOUT
LOG.info("[%s] Waiting for Word VM to convert (timeout=%ds)...", job_id[:8], DOCSERVER_TIMEOUT)
while time.monotonic() < deadline:
try:
mc.stat_object(_MINIO_BUCKET, out_key)
# Object exists — download it
LOG.info("[%s] PDF ready — downloading", job_id[:8])
mc.fget_object(_MINIO_BUCKET, out_key, str(pdf_path))
# Clean up the converted output from MinIO
try:
mc.remove_object(_MINIO_BUCKET, out_key)
except Exception:
pass
LOG.info("[%s] PDF written: %s (%d bytes)", job_id[:8], pdf_path.name, pdf_path.stat().st_size)
return pdf_path
except Exception:
# Object not there yet — keep waiting
time.sleep(_POLL_INTERVAL)
# Timed out — clean up the orphaned DOCX and raise
try:
mc.remove_object(_MINIO_BUCKET, in_key)
except Exception:
pass
raise TimeoutError(
f"Word VM did not convert {docx_path.name} within {DOCSERVER_TIMEOUT}s. "
f"Is docserver_worker.py running and connected to MinIO?"
)
def _batch_via_minio(docx_paths: list[Path], output_dir: Path) -> list[Path]:
"""Submit all DOCX files in parallel, collect results."""
import threading
results: list[Path | None] = [None] * len(docx_paths)
errors: list[str | None] = [None] * len(docx_paths)
def _convert_one(idx: int, docx_path: Path) -> None:
pdf_path = output_dir / docx_path.with_suffix(".pdf").name
try:
results[idx] = _convert_via_minio(docx_path, pdf_path)
except Exception as exc:
LOG.error("Batch item %d (%s) failed: %s", idx, docx_path.name, exc)
errors[idx] = str(exc)
# Fallback per-file
try:
results[idx] = _convert_via_libreoffice(docx_path, pdf_path, output_dir)
except Exception as lo_exc:
LOG.error("LibreOffice fallback also failed for %s: %s", docx_path.name, lo_exc)
threads = [
threading.Thread(target=_convert_one, args=(i, p), daemon=True)
for i, p in enumerate(docx_paths)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=DOCSERVER_TIMEOUT + 10)
return [r for r in results if r is not None]
# ── LibreOffice fallback ──────────────────────────────────────────────────────
def _convert_via_libreoffice(docx_path: Path, pdf_path: Path, out_dir: Path) -> Path:
"""Convert DOCX to PDF using LibreOffice headless (fallback)."""
LOG.info("Converting %s via LibreOffice headless...", docx_path.name)
cmd = [
"libreoffice", "--headless",
"--convert-to", "pdf",
"--outdir", str(out_dir),
str(docx_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
LOG.error("LibreOffice conversion failed: %s", result.stderr)
raise RuntimeError(f"LibreOffice failed: {result.stderr[:300]}")
if not pdf_path.exists():
raise RuntimeError(f"PDF not found at expected path after LibreOffice: {pdf_path}")
LOG.info("PDF created via LibreOffice: %s (%d bytes)", pdf_path.name, pdf_path.stat().st_size)
return pdf_path

View file

@ -0,0 +1,213 @@
"""
CALEA SSI Plan Audio Bridging / Conferencing variant.
Audio bridging / conferencing is narrowly scoped for CALEA purposes.
To the extent the service qualifies as an information service rather
than as telecommunications (47 USC § 153(24) vs. § 153(53)), the CALEA
covered-entity definition at 47 USC § 1001(8)(B)(ii) may not apply.
For the telecommunications-service portion that does apply, intercept
capability is provisioned at the bridge/softswitch; non-real-time
replay of recordings is treated as a stored-record production under
18 USC § 2703 rather than as a real-time intercept under Title III.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_audio_bridge")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA Audio Bridge unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "audio_bridging"
VARIANT_LABEL = "Audio Bridging / Conferencing"
def _heading(doc, text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(4)
r = p.add_run(text); r.bold = True; r.font.size = Pt(13); r.font.color.rgb = NAVY
def _body(doc, text, bold=False):
p = doc.add_paragraph(); p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
def _bullets(doc, items):
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear(); r = p.add_run(it); r.font.size = Pt(11)
def generate_calea_audio_bridge(
output_path: str,
entity_name: str,
frn: str = "",
law_enforcement_contact: Optional[dict] = None,
cpni_protection_officer: Optional[dict] = None,
network_infrastructure_summary: str = "",
interception_support_method: str = "",
reporting_year: int = 0,
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
effective_date: str = "",
next_review_date: str = "",
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
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)
title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name); sr.font.size = Pt(13); sr.bold = True
vsub = doc.add_paragraph(); vsub.alignment = WD_ALIGN_PARAGRAPH.CENTER
vr = vsub.add_run(f"Variant: {VARIANT_LABEL}")
vr.font.size = Pt(11); vr.italic = True
cite = doc.add_paragraph(); cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run("Pursuant to 47 U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003")
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
_heading(doc, "1. Purpose and Scope Note")
_body(doc, (
f"This SSI Plan governs {entity_name}'s compliance with CALEA to "
f"the extent {entity_name}'s audio bridging / conferencing "
f"offerings constitute telecommunications service under 47 USC "
f"\u00a7 153(53) or are otherwise within the CALEA covered-entity "
f"definition at 47 USC \u00a7 1001(8). Portions of the service "
f"that constitute information service under 47 USC \u00a7 153(24) "
f"are outside the scope of CALEA."
))
_heading(doc, "2. Scope and Applicability")
_body(doc, (
f"{entity_name} operates conference-bridge / softswitch elements "
f"and ingress / egress SIP trunks connecting to the PSTN. For "
f"the telecommunications-service portion of the offering, CALEA "
f"obligations attach; for any information-service portion "
f"(non-real-time recorded replay, data-only collaboration "
f"features), CALEA does not apply."
))
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the "
f"following senior officer as 24-hour contact for law enforcement "
f"service of process (court orders, pen register / trap-and-trace, "
f"Title III)."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_heading(doc, "4. Network Architecture and Interception Capability")
_body(doc, network_infrastructure_summary or (
f"{entity_name} operates a conference-bridge softswitch with SIP "
"trunks for inbound / outbound PSTN connectivity. Participant "
"identities are captured via caller-ID plus dial-in PIN."
))
_body(doc, interception_support_method or (
f"For real-time intercept orders directed at an identified "
f"participant or conference, {entity_name} provisions LI at the "
f"conference-bridge softswitch, mirroring content and "
f"call-identifying information to the requesting law-enforcement "
f"agency in a CALEA-safe-harbor-compliant format. Non-real-time "
f"productions (recorded conferences lawfully requested under "
f"18 USC \u00a7 2703) are handled as stored-record productions "
f"under the separate subpoena-response procedure and are not "
f"treated as real-time intercepts."
))
_heading(doc, "5. CPNI Safeguards")
_body(doc, (
f"{entity_name} maintains a separate CPNI procedure statement. "
f"The CPNI Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
"Annual CALEA + CPNI training for personnel with bridge-admin or "
"subpoena-response duties.",
"Background checks performed prior to grant of access.",
"Access revoked within 24 hours of termination.",
"Bridge-admin and LI actions attributed to authenticated named "
"users.",
])
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews LI "
f"and subpoena-response activity at least quarterly."
))
_heading(doc, "8. Records Retention")
_body(doc, (
"LI provisioning and service-of-process records retained ten (10) "
"years per 47 CFR \u00a7 1.20003(b); CPNI access logs retained at "
"least two (2) years per 47 CFR \u00a7 64.2009."
))
_heading(doc, "9. Annual Review")
_body(doc, (
f"Reviewed at least annually. Next scheduled review: {next_review}."
))
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have "
f"reviewed this SSI Plan and that {entity_name} complies with 47 "
f"U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003 with respect to "
f"the telecommunications-service portion of its offerings."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA Audio Bridge SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,250 @@
"""
CALEA System Security and Integrity (SSI) Plan CLEC SS7 / facilities.
Tailored variant of the generic CALEA SSI plan for a Competitive Local
Exchange Carrier that operates its own TDM / SS7 / SIGTRAN switching
infrastructure. The lawful-intercept method is provisioned at the Class 5
softswitch and at the SS7 / SIGTRAN STPs using the industry-standard
ATIS J-STD-025 interface. CALEA scope covers both local-exchange
switching and resold access transport.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_clec_ss7")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA CLEC SS7 unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "clec_ss7"
VARIANT_LABEL = "Competitive Local Exchange Carrier — SS7 / SIGTRAN"
def _heading(doc, text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(4)
r = p.add_run(text); r.bold = True; r.font.size = Pt(13)
r.font.color.rgb = NAVY
def _body(doc, text, bold=False):
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
def _bullets(doc, items):
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear()
r = p.add_run(it); r.font.size = Pt(11)
def generate_calea_clec_ss7(
output_path: str,
entity_name: str,
frn: str = "",
law_enforcement_contact: Optional[dict] = None,
cpni_protection_officer: Optional[dict] = None,
network_infrastructure_summary: str = "",
interception_support_method: str = "",
reporting_year: int = 0,
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
effective_date: str = "",
next_review_date: str = "",
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
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)
title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name)
sr.font.size = Pt(13); sr.bold = True
vsub = doc.add_paragraph(); vsub.alignment = WD_ALIGN_PARAGRAPH.CENTER
vr = vsub.add_run(f"Variant: {VARIANT_LABEL}")
vr.font.size = Pt(11); vr.italic = True
cite = doc.add_paragraph(); cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run(
"Pursuant to 47 U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003"
)
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
_heading(doc, "1. Purpose")
_body(doc, (
f"This System Security and Integrity (SSI) Plan governs {entity_name}'s "
f"compliance with the Communications Assistance for Law Enforcement "
f"Act (CALEA), 47 U.S.C. \u00a7\u00a7 1001\u20131010, and the "
f"Commission's rules at 47 CFR Part 1 Subpart Z, as applied to "
f"{entity_name}'s operations as a Competitive Local Exchange Carrier "
f"(CLEC) with SS7 / SIGTRAN switching infrastructure."
))
_heading(doc, "2. Scope and Applicability")
_body(doc, (
f"{entity_name} is subject to CALEA as a facilities-based provider "
f"of common-carrier local exchange service. Its covered equipment "
f"includes Class 5 softswitch(es), trunk gateways, SS7 / SIGTRAN "
f"STPs, and signaling-link interconnections to interexchange "
f"carriers and to the public switched telephone network."
))
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the "
f"following senior officer as point of contact for law enforcement "
f"inquiries, court orders, pen register / trap-and-trace orders, "
f"and Title III wiretap orders. This contact is staffed 24 hours "
f"a day, 365 days a year."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_body(doc, (
f"Service of process may be made on the above designee by "
f"telephone, email, or in person. {entity_name} commits to "
f"acknowledging any intercept or traffic-capture order within "
f"two (2) business hours of receipt."
))
_heading(doc, "4. Network Architecture and Interception Capability")
_body(doc, network_infrastructure_summary or (
f"{entity_name} operates a Class 5 softswitch (or TDM Class 5 "
"switch where retained) supported by redundant SS7 / SIGTRAN "
"signaling through owned or leased STPs. Customer access is "
"provided via copper loops, fiber, and resold UNE-P/loop "
"facilities where applicable. Interconnection with the PSTN is "
"by SS7 trunks to the relevant tandems."
))
_body(doc, interception_support_method or (
f"Lawful intercept is provisioned at the Class 5 softswitch and "
"at the SS7 / SIGTRAN STP in accordance with ATIS J-STD-025-B "
"(TIA/ANSI-41/GSM LAES). Call content is delivered to the "
"requesting law-enforcement agency via a Call Content Channel "
"(CCC) and call-identifying information via a Call Data Channel "
"(CDC), following the safe-harbor industry standard adopted by "
"the FCC under 47 CFR Part 1 Subpart Z. The Designated Senior "
"Officer coordinates provisioning, validates the court order, "
"and certifies activation to law enforcement."
))
_body(doc, (
f"{entity_name} retains copies of ATIS J-STD-025 compliance "
f"attestations from its switch and SS7 vendors, and maintains "
f"interconnection agreements with its tandem provider(s) that "
f"address CALEA responsibilities."
))
_heading(doc, "5. CPNI Safeguards")
_body(doc, (
f"{entity_name} maintains a separate, written CPNI procedure "
f"statement under 47 CFR \u00a7\u00a7 64.2001\u201364.2011. The "
f"CPNI Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_body(doc, (
"SS7 / SIGTRAN LIDB access, PIC records, and intercept "
"provisioning are all within the CPNI Protection Officer's "
"oversight scope."
))
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
f"All {entity_name} personnel with access to intercept "
"provisioning interfaces complete annual CALEA and CPNI training.",
"Background checks are performed prior to granting access.",
"Access is revoked within 24 hours of termination.",
"All intercept-related actions are attributed to named "
"individuals via authenticated logins (no shared credentials).",
])
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews "
f"intercept-related activity at least quarterly. Anomalies "
f"(unauthorized access attempts, tampering, missed response SLAs) "
f"are escalated to the CEO within one business day of detection."
))
_heading(doc, "8. Records Retention")
_body(doc, (
"Records of intercept provisioning, service of process, "
"acknowledgments, and termination are retained for a minimum of "
"ten (10) years per 47 CFR \u00a7 1.20003(b). CPNI access logs "
"are retained at least two (2) years per 47 CFR \u00a7 64.2009."
))
_heading(doc, "9. Annual Review")
_body(doc, (
f"This Plan is reviewed at least annually and updated upon "
f"(i) material change to the switching infrastructure, "
f"(ii) change of upstream tandem or IXC interconnection, "
f"(iii) new Commission / DOJ guidance, or (iv) a material breach "
f"or near-miss. Next scheduled review: {next_review}."
))
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have "
f"reviewed this SSI Plan and that {entity_name} has implemented "
f"the policies, procedures, and technical measures described "
f"herein, and complies with 47 U.S.C. \u00a7 229 and 47 CFR "
f"\u00a7 1.20003."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn:
_body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA CLEC SS7 SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,219 @@
"""
CALEA System Security and Integrity (SSI) Plan IXC SS7.
Interexchange-carrier variant of the CALEA SSI plan. The lawful-
intercept method is provisioned at IXC tandem / Class 4 switching
elements (or softswitch equivalents) using SS7 signaling, and covers
both content and call-identifying information for pen-register /
trap-and-trace and Title III orders directed at toll calls.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_ixc_ss7")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA IXC SS7 unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "ixc_ss7"
VARIANT_LABEL = "Interexchange Carrier — SS7 / SIGTRAN"
def _heading(doc, text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(4)
r = p.add_run(text); r.bold = True; r.font.size = Pt(13); r.font.color.rgb = NAVY
def _body(doc, text, bold=False):
p = doc.add_paragraph(); p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
def _bullets(doc, items):
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear()
r = p.add_run(it); r.font.size = Pt(11)
def generate_calea_ixc_ss7(
output_path: str,
entity_name: str,
frn: str = "",
law_enforcement_contact: Optional[dict] = None,
cpni_protection_officer: Optional[dict] = None,
network_infrastructure_summary: str = "",
interception_support_method: str = "",
reporting_year: int = 0,
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
effective_date: str = "",
next_review_date: str = "",
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
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)
title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name); sr.font.size = Pt(13); sr.bold = True
vsub = doc.add_paragraph(); vsub.alignment = WD_ALIGN_PARAGRAPH.CENTER
vr = vsub.add_run(f"Variant: {VARIANT_LABEL}")
vr.font.size = Pt(11); vr.italic = True
cite = doc.add_paragraph(); cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run("Pursuant to 47 U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003")
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
_heading(doc, "1. Purpose")
_body(doc, (
f"This SSI Plan governs {entity_name}'s compliance with CALEA and "
f"the Commission's implementing rules as applied to {entity_name}'s "
f"operations as an interexchange (toll) carrier utilizing SS7 / "
f"SIGTRAN signaling."
))
_heading(doc, "2. Scope and Applicability")
_body(doc, (
f"{entity_name} is a provider of interexchange (toll) service "
f"subject to CALEA. Its covered equipment includes IXC tandem / "
f"Class 4 switching elements or softswitch equivalents, SS7 / "
f"SIGTRAN signaling, billing-record systems, and trunk "
f"interconnections with IXC peers, LECs, and wireless carriers."
))
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the "
f"following 24-hour point of contact for court orders, pen "
f"register / trap-and-trace orders, and Title III wiretap orders."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_body(doc, (
f"{entity_name} commits to acknowledging any order within two (2) "
f"business hours of receipt."
))
_heading(doc, "4. Network Architecture and Interception Capability")
_body(doc, network_infrastructure_summary or (
f"{entity_name} operates softswitch / Class 4 tandem elements "
"with redundant SS7 / SIGTRAN signaling for toll call control. "
"Trunk peering with LECs, other IXCs, and wireless carriers is "
"established via SS7 trunks."
))
_body(doc, interception_support_method or (
f"Toll-call lawful intercept is provisioned at {entity_name}'s "
"softswitch / tandem under ATIS J-STD-025-B. Call content is "
"delivered via Call Content Channel (CCC); call-identifying "
"information via Call Data Channel (CDC). The Designated Senior "
"Officer validates the court order, coordinates provisioning, "
"and certifies activation to the requesting law-enforcement "
"agency."
))
_heading(doc, "5. CPNI Safeguards")
_body(doc, (
f"{entity_name} maintains a separate CPNI procedure statement "
f"under 47 CFR \u00a7\u00a7 64.2001\u201364.2011. The CPNI "
f"Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_body(doc, (
"Toll call-record databases, PIC-administration interfaces, and "
"intercept provisioning are all within the CPNI Protection "
"Officer's oversight scope."
))
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
"Annual CALEA + CPNI training for all personnel with intercept "
"or CPNI access.",
"Background checks prior to grant of access.",
"Access revoked within 24 hours of termination.",
"All intercept actions attributed to authenticated named users.",
])
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews "
f"intercept activity at least quarterly. Anomalies are escalated "
f"to the CEO within one business day."
))
_heading(doc, "8. Records Retention")
_body(doc, (
"Intercept provisioning and service-of-process records retained "
"ten (10) years per 47 CFR \u00a7 1.20003(b); CPNI access logs "
"retained at least two (2) years per 47 CFR \u00a7 64.2009."
))
_heading(doc, "9. Annual Review")
_body(doc, (
f"This Plan is reviewed at least annually and upon material "
f"infrastructure / interconnection change. Next scheduled review: "
f"{next_review}."
))
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have "
f"reviewed this SSI Plan and that {entity_name} complies with 47 "
f"U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA IXC SS7 SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,220 @@
"""
CALEA SSI Plan Satellite (MSS / FSS) variant.
Satellite carriers that provide telecommunications service (MSS) or
facilitate it (certain FSS transport arrangements) are subject to CALEA.
Intercept capability is generally provisioned at the ground segment
i.e., the earth station / NOC / IP gateway where subscriber sessions
terminate before hand-off to the PSTN or public Internet. This variant
documents the ground-segment intercept model, the Part 25 physical-
security controls, and the delegation to terrestrial-network partners
where applicable.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_satellite")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA Satellite unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "satellite"
VARIANT_LABEL = "Satellite (MSS / FSS)"
def _heading(doc, text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(4)
r = p.add_run(text); r.bold = True; r.font.size = Pt(13); r.font.color.rgb = NAVY
def _body(doc, text, bold=False):
p = doc.add_paragraph(); p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
def _bullets(doc, items):
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear(); r = p.add_run(it); r.font.size = Pt(11)
def generate_calea_satellite(
output_path: str,
entity_name: str,
frn: str = "",
law_enforcement_contact: Optional[dict] = None,
cpni_protection_officer: Optional[dict] = None,
network_infrastructure_summary: str = "",
interception_support_method: str = "",
reporting_year: int = 0,
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
effective_date: str = "",
next_review_date: str = "",
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
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)
title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name); sr.font.size = Pt(13); sr.bold = True
vsub = doc.add_paragraph(); vsub.alignment = WD_ALIGN_PARAGRAPH.CENTER
vr = vsub.add_run(f"Variant: {VARIANT_LABEL}")
vr.font.size = Pt(11); vr.italic = True
cite = doc.add_paragraph(); cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run(
"Pursuant to 47 U.S.C. \u00a7 229, 47 CFR \u00a7 1.20003, and "
"47 CFR Part 25"
)
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
_heading(doc, "1. Purpose")
_body(doc, (
f"This SSI Plan governs {entity_name}'s compliance with CALEA and "
f"47 CFR \u00a7 1.20003 as applied to {entity_name}'s operations "
f"as a provider of Mobile Satellite Service and/or Fixed Satellite "
f"Service telecommunications."
))
_heading(doc, "2. Scope and Applicability")
_body(doc, (
f"{entity_name} operates (or leases capacity from) a space segment "
f"and ground-segment infrastructure including earth stations, "
f"gateway facilities, and a network operations center (NOC). "
f"Subscriber sessions originate at user terminals, traverse the "
f"satellite, and terminate at ground-segment gateways before "
f"hand-off to terrestrial networks. CALEA obligations apply to "
f"the telecommunications-service portions of this traffic."
))
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the "
f"following senior officer as 24-hour contact for law enforcement "
f"service of process."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_heading(doc, "4. Network Architecture and Interception Capability")
_body(doc, network_infrastructure_summary or (
f"{entity_name}'s ground segment comprises earth-station antennas, "
"baseband modems, gateway routing / softswitch elements, and NOC "
"monitoring. Physical access to these facilities is controlled "
"under the Part 25 earth-station license conditions."
))
_body(doc, interception_support_method or (
f"Lawful intercept is provisioned at the ground segment where "
"subscriber sessions are decrypted / de-encapsulated for hand-off. "
"Content and call-identifying information are delivered to the "
"requesting law-enforcement agency using the CALEA safe-harbor "
"interfaces (ATIS J-STD-025 for voice / TIA TR-45 equivalents for "
"data) or a mutually agreed alternative acceptable under 47 CFR "
"Part 1 Subpart Z. Where {entity_name} hands off traffic to a "
"terrestrial partner for switching or transport, the partner "
"supports intercept under its own CALEA plan and the plans are "
"coordinated."
).replace("{entity_name}", entity_name))
_heading(doc, "5. CPNI Safeguards")
_body(doc, (
f"{entity_name} maintains separate CPNI procedures under 47 CFR "
f"\u00a7\u00a7 64.2001\u201364.2011. Customer activation records, "
f"beam / transponder assignments, and NOC operator logs are "
f"within the CPNI Protection Officer's oversight. The CPNI "
f"Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
"Annual CALEA + CPNI training for NOC operators and LI-provisioning "
"personnel.",
"Background checks performed prior to granting access to ground-"
"segment provisioning systems.",
"Physical access to earth-station / NOC facilities controlled per "
"Part 25 license conditions.",
"LI provisioning attributed to named authenticated users.",
])
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews LI "
f"activity and NOC operator access logs at least quarterly."
))
_heading(doc, "8. Records Retention")
_body(doc, (
"LI provisioning and service-of-process records retained ten (10) "
"years per 47 CFR \u00a7 1.20003(b); CPNI access logs retained at "
"least two (2) years per 47 CFR \u00a7 64.2009."
))
_heading(doc, "9. Annual Review")
_body(doc, (
f"Reviewed at least annually and upon material change to the "
f"ground segment, earth-station license, or terrestrial hand-off "
f"arrangement. Next scheduled review: {next_review}."
))
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have "
f"reviewed this SSI Plan and that {entity_name} complies with 47 "
f"U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA Satellite SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,308 @@
"""
CALEA System Security and Integrity (SSI) Plan generator.
Under 47 USC § 229 and 47 CFR § 1.20003 every telecommunications carrier
(including interconnected VoIP providers) must maintain and review
annually a System Security and Integrity policy covering lawful-
intercept capability, CPNI safeguards, personnel vetting, supervisory
review, and records retention. The SSI plan is kept internally. It's
produced for DOJ on subpoena (28 CFR § 100.10) not routinely filed
with the FCC.
We generate a carrier-specific, signable DOCX that follows the 10-section
outline expected by DOJ / FCC Enforcement reviewers. Customer-specific
substitutions come from ``intake_data["calea_ssi"]``.
Usage:
from scripts.document_gen.templates.calea_ssi_generator import (
generate_calea_ssi_plan,
)
path = generate_calea_ssi_plan(
entity_name="Falcon Broadband LLC",
law_enforcement_contact={"name":"Jane Doe","title":"General Counsel",
"phone":"555-123-4567","email_24h":"le-contact@falcon.example.com"},
cpni_protection_officer={"name":"John Roe","title":"VP Operations"},
network_infrastructure_summary="FreeSWITCH cluster + Ribbon SBC; "
"trunking via Bandwidth.com + Inteliquent",
interception_support_method="CALEA intercept provided by our upstream "
"provider Bandwidth.com under the standard "
"CALEA Reference Model for VoIP",
output_path="/tmp/calea_ssi.docx",
)
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_ssi")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA SSI generation unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
def _heading(doc, text: str) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(4)
run = p.add_run(text)
run.bold = True
run.font.size = Pt(13)
run.font.color.rgb = NAVY
def _body(doc, text: str, bold: bool = False) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(6)
run = p.add_run(text)
run.font.size = Pt(11)
run.bold = bold
def _bullets(doc, items: list[str]) -> None:
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear()
run = p.add_run(it)
run.font.size = Pt(11)
def generate_calea_ssi_plan(
# Carrier identity
entity_name: str,
frn: str = "",
# Law enforcement designated 24-hour contact (47 CFR § 1.20003(a)(1))
law_enforcement_contact: Optional[dict] = None,
# CPNI protection officer (47 CFR § 64.2009(d))
cpni_protection_officer: Optional[dict] = None,
# Network / infrastructure
network_infrastructure_summary: str = "",
interception_support_method: str = "",
# Operational scope
is_interconnected_voip: bool = True,
is_wholesale: bool = False,
has_retail_customers: bool = True,
# Signatory (typically the officer named on the CPNI cert)
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
# Dates
effective_date: str = "",
next_review_date: str = "",
# Reviewer (PW compliance team)
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
# Output
output_path: str = "/tmp/calea_ssi_plan.docx",
) -> Optional[str]:
"""Produce the 10-section CALEA SSI Plan as a DOCX."""
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
# Title
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph()
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name)
sr.font.size = Pt(13); sr.bold = True
cite = doc.add_paragraph()
cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run(
"Pursuant to 47 U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003"
)
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
# ── 1. Purpose ──────────────────────────────────────────────────
_heading(doc, "1. Purpose")
_body(doc, (
f"This System Security and Integrity (SSI) Plan governs {entity_name}'s "
f"compliance with the Communications Assistance for Law Enforcement "
f"Act (CALEA), 47 U.S.C. \u00a7\u00a7 1001\u20131010, and the Federal "
f"Communications Commission's implementing rules at 47 CFR Part 1 "
f"Subpart Z. It defines the procedures {entity_name} uses to "
f"support lawful electronic surveillance of its telecommunications "
f"and interconnected VoIP services while protecting customer "
f"privacy and the integrity of company operations."
))
# ── 2. Scope and Applicability ──────────────────────────────────
_heading(doc, "2. Scope and Applicability")
scope_bits = [f"{entity_name} is subject to CALEA as a provider of "]
if is_interconnected_voip:
scope_bits.append("interconnected Voice over Internet Protocol services ")
scope_bits.append("and has designed and implemented the systems described "
"herein to support lawful intercept obligations.")
_body(doc, "".join(scope_bits))
if has_retail_customers:
_body(doc, (
f"{entity_name} maintains retail customer relationships subject "
f"to the CPNI safeguards defined in Section 5."
))
if is_wholesale:
_body(doc, (
f"{entity_name} also operates in a wholesale capacity. Wholesale "
f"intercept requests are coordinated with the downstream service "
f"provider per the CALEA Reference Model."
))
# ── 3. Designated Law Enforcement Contact ──────────────────────
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the following "
f"senior officer as point of contact for law enforcement inquiries, "
f"court orders, pen register / trap-and-trace orders, and Title III "
f"wiretap orders. This contact is staffed 24 hours a day."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_body(doc, (
f"Law enforcement officers may effect service of process on the "
f"above designee by telephone, email, or in person at the address "
f"of record. {entity_name} commits to acknowledging any intercept "
f"or traffic-capture order within two (2) business hours of receipt."
))
# ── 4. Network Architecture ────────────────────────────────────
_heading(doc, "4. Network Architecture and Interception Capability")
_body(doc, network_infrastructure_summary or (
f"{entity_name} operates a VoIP network consisting of session "
"border controllers (SBCs) for signaling, softswitch(es) for "
"call control, and DID origination / termination via "
"commercial-grade upstream providers."
))
_body(doc, interception_support_method or (
f"CALEA intercept capability is provided through {entity_name}'s "
"upstream voice service provider(s) under the standard CALEA "
"Reference Model for interconnected VoIP. Upon receipt of a valid "
"court order, the designated law enforcement contact coordinates "
"with the upstream provider's CALEA team to provision the intercept "
"at the upstream switching element."
))
_body(doc, (
f"{entity_name} retains documentation of CALEA implementation "
f"capability including upstream provider CALEA attestations, "
f"interconnection agreements, and ATIS J-STD-025 / TIA-J-STD-025 "
f"compliance references."
))
# ── 5. CPNI Safeguards ─────────────────────────────────────────
_heading(doc, "5. Customer Proprietary Network Information (CPNI) Safeguards")
_body(doc, (
f"{entity_name} maintains separate, written CPNI procedures under "
f"47 CFR \u00a7\u00a7 64.2001\u201364.2011. The CPNI Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_body(doc, (
"Access to CPNI is authorized only for legitimate business "
"purposes, supervised by the CPNI Protection Officer, and logged "
"for supervisory review. See the company's separate CPNI "
"Procedure Statement for detailed controls."
))
# ── 6. Personnel Vetting and Training ─────────────────────────
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
f"All {entity_name} personnel with access to intercept systems or "
"CPNI complete annual CALEA and CPNI training.",
"Background checks are performed on all personnel prior to being "
"granted access to intercept provisioning interfaces.",
"Access is revoked within 24 hours of termination of employment.",
"All intercept-related actions are attributed to named individuals "
"via authenticated logins (no shared credentials).",
])
# ── 7. Supervisory Review ─────────────────────────────────────
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews all "
f"intercept-related activity no less than quarterly. Any anomaly "
f"(unauthorized access attempt, tampering, missed response SLA) "
f"is escalated to the CEO within one business day of detection."
))
# ── 8. Records Retention ──────────────────────────────────────
_heading(doc, "8. Records Retention")
_body(doc, (
"Records of intercept provisioning, service of process, "
"acknowledgments, and termination are retained for a minimum of "
"ten (10) years per 47 CFR \u00a7 1.20003(b). CPNI access logs are "
"retained for at least two (2) years per 47 CFR \u00a7 64.2009(c)."
))
# ── 9. Annual Review ──────────────────────────────────────────
_heading(doc, "9. Annual Review")
_body(doc, (
f"This Plan is reviewed at least annually by the designated senior "
f"officer and updated when: (i) a new class of service is offered, "
f"(ii) an upstream provider material to CALEA intercept capability "
f"changes, (iii) the FCC or DOJ issues new guidance, or (iv) a "
f"material breach or near-miss is identified. Next scheduled "
f"review: {next_review}."
))
# ── 10. Certification and Signature ───────────────────────────
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have reviewed "
f"this System Security and Integrity Plan and that {entity_name} "
f"has implemented the policies, procedures, and technical measures "
f"described herein. I further certify that {entity_name} complies "
f"with 47 U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003, and that "
f"{entity_name} will make this Plan available to the Commission "
f"and the Department of Justice on request."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn:
_body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,231 @@
"""
CALEA System Security and Integrity (SSI) Plan Wireless (CMRS) variant.
Facilities-based wireless carrier SSI plan. LAES (Lawfully Authorized
Electronic Surveillance) capability is provisioned at the Mobile
Switching Center (MSC) / 4G EPC / 5G Core per 47 CFR § 20.13 and the
ATIS/3GPP LI standards. Content and call-identifying information are
delivered to law enforcement over the standardized LI interfaces (X1 /
X2 / X3 for 3GPP). The Plan also addresses per-device location data
as a CPNI safeguard integration point.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_wireless")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA Wireless unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "wireless"
VARIANT_LABEL = "Wireless (CMRS) Facilities"
def _heading(doc, text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(4)
r = p.add_run(text); r.bold = True; r.font.size = Pt(13); r.font.color.rgb = NAVY
def _body(doc, text, bold=False):
p = doc.add_paragraph(); p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
def _bullets(doc, items):
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear(); r = p.add_run(it); r.font.size = Pt(11)
def generate_calea_wireless(
output_path: str,
entity_name: str,
frn: str = "",
law_enforcement_contact: Optional[dict] = None,
cpni_protection_officer: Optional[dict] = None,
network_infrastructure_summary: str = "",
interception_support_method: str = "",
reporting_year: int = 0,
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
effective_date: str = "",
next_review_date: str = "",
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
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)
title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name); sr.font.size = Pt(13); sr.bold = True
vsub = doc.add_paragraph(); vsub.alignment = WD_ALIGN_PARAGRAPH.CENTER
vr = vsub.add_run(f"Variant: {VARIANT_LABEL}")
vr.font.size = Pt(11); vr.italic = True
cite = doc.add_paragraph(); cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run(
"Pursuant to 47 U.S.C. \u00a7 229, 47 CFR \u00a7 1.20003, "
"and 47 CFR \u00a7 20.13"
)
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
_heading(doc, "1. Purpose")
_body(doc, (
f"This SSI Plan governs {entity_name}'s compliance with CALEA "
f"(47 U.S.C. \u00a7\u00a7 1001\u20131010) and the Commission's "
f"rules at 47 CFR Part 1 Subpart Z and 47 CFR \u00a7 20.13 as "
f"applied to {entity_name}'s operations as a facilities-based "
f"Commercial Mobile Radio Service (CMRS) provider."
))
_heading(doc, "2. Scope and Applicability")
_body(doc, (
f"{entity_name} is a facilities-based CMRS provider subject to "
f"the Lawfully Authorized Electronic Surveillance (LAES) "
f"obligations of 47 CFR \u00a7 20.13. Its covered equipment "
f"includes the Mobile Switching Center (MSC), 4G Evolved Packet "
f"Core (EPC), 5G Core, HSS / UDM, SMS-C, and the associated "
f"radio-access network (eNB / gNB) provisioning systems."
))
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the "
f"following senior officer as 24-hour point of contact for court "
f"orders, pen-register/trap-and-trace orders, Title III wiretap "
f"orders, and location-information orders."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_body(doc, (
f"{entity_name} commits to acknowledging any order within two (2) "
f"business hours of receipt."
))
_heading(doc, "4. Network Architecture and Interception Capability")
_body(doc, network_infrastructure_summary or (
f"{entity_name} operates a radio-access network (eNB / gNB), a 4G "
"EPC with MME / S-GW / P-GW elements, and where deployed a 5G "
"Core with AMF / SMF / UPF. Subscriber identity and location are "
"held in the HSS / UDM. Voice service is delivered via IMS / "
"VoLTE or via circuit-switched fallback."
))
_body(doc, interception_support_method or (
f"Lawful intercept (LAES) is provisioned at {entity_name}'s MSC / "
"EPC / 5GC elements using the 3GPP-standardized LI interfaces "
"(X1 for provisioning / administration, X2 for intercept-related "
"information, X3 for content-of-communications) per 3GPP TS "
"33.126 / 33.127 / 33.128 and ATIS T1.724 / J-STD-025. Call "
"content and call-identifying information (including cell-site "
"/ E911 / handover location data where lawfully ordered) are "
"delivered to the requesting agency through these standard "
"interfaces."
))
_heading(doc, "5. CPNI Safeguards")
_body(doc, (
f"{entity_name} maintains a separate CPNI procedure statement "
f"under 47 CFR \u00a7\u00a7 64.2001\u201364.2011. Device-level "
f"location data is treated as CPNI, consistent with the "
f"Commission's 2020 LocationSmart Consent Decree (DA 20-299) "
f"and 2024 NAL against unauthorized third-party location sharing. "
f"The CPNI Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
"Annual CALEA + CPNI training for all personnel with LI or CPNI "
"access.",
"Background checks performed prior to granting access to LI "
"provisioning or HSS / UDM systems.",
"Access revoked within 24 hours of termination.",
"All LI actions attributed to authenticated named users; no "
"shared credentials.",
])
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews "
f"LI activity logs at least quarterly. Anomalies are escalated "
f"to the CEO within one business day."
))
_heading(doc, "8. Records Retention")
_body(doc, (
"LI provisioning and service-of-process records retained ten (10) "
"years per 47 CFR \u00a7 1.20003(b); CPNI access logs retained at "
"least two (2) years per 47 CFR \u00a7 64.2009."
))
_heading(doc, "9. Annual Review")
_body(doc, (
f"This Plan is reviewed at least annually and upon (i) material "
f"core or RAN network change, (ii) new 3GPP LI release adoption, "
f"(iii) new Commission / DOJ guidance, or (iv) a material breach. "
f"Next scheduled review: {next_review}."
))
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have "
f"reviewed this SSI Plan and that {entity_name} complies with "
f"47 U.S.C. \u00a7 229, 47 CFR \u00a7 1.20003, and 47 CFR "
f"\u00a7 20.13."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA Wireless SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,229 @@
"""
CALEA SSI Plan Wireless MVNO variant.
A Mobile Virtual Network Operator has no radio-access network and no
core-network switching of its own. Under the CALEA Reference Model, the
host MNO is responsible for actual lawful-intercept provisioning and
delivery; the MVNO's SSI Plan documents the division of responsibility,
the designated point of contact for law enforcement service of process,
and the contractual flow-down terms that obligate the host MNO to
support intercepts initiated against the MVNO's subscribers.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.calea_wireless_mvno")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CALEA Wireless MVNO unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "wireless_mvno"
VARIANT_LABEL = "Wireless (CMRS) — MVNO"
def _heading(doc, text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(4)
r = p.add_run(text); r.bold = True; r.font.size = Pt(13); r.font.color.rgb = NAVY
def _body(doc, text, bold=False):
p = doc.add_paragraph(); p.paragraph_format.space_after = Pt(6)
r = p.add_run(text); r.font.size = Pt(11); r.bold = bold
def _bullets(doc, items):
for it in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear(); r = p.add_run(it); r.font.size = Pt(11)
def generate_calea_wireless_mvno(
output_path: str,
entity_name: str,
frn: str = "",
law_enforcement_contact: Optional[dict] = None,
cpni_protection_officer: Optional[dict] = None,
network_infrastructure_summary: str = "",
interception_support_method: str = "",
reporting_year: int = 0,
host_mno_name: str = "",
signatory_name: str = "",
signatory_title: str = "Chief Executive Officer",
effective_date: str = "",
next_review_date: str = "",
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
le = law_enforcement_contact or {}
cpni = cpni_protection_officer or {}
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
host = host_mno_name or "its host Mobile Network Operator"
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)
title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title.add_run("System Security and Integrity (SSI) Plan")
tr.font.size = Pt(15); tr.bold = True; tr.font.color.rgb = NAVY
sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sr = sub.add_run(entity_name); sr.font.size = Pt(13); sr.bold = True
vsub = doc.add_paragraph(); vsub.alignment = WD_ALIGN_PARAGRAPH.CENTER
vr = vsub.add_run(f"Variant: {VARIANT_LABEL}")
vr.font.size = Pt(11); vr.italic = True
cite = doc.add_paragraph(); cite.alignment = WD_ALIGN_PARAGRAPH.CENTER
cr = cite.add_run("Pursuant to 47 U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003")
cr.font.size = Pt(10); cr.italic = True
cite.paragraph_format.space_after = Pt(18)
_heading(doc, "1. Purpose")
_body(doc, (
f"This SSI Plan governs {entity_name}'s compliance with CALEA and "
f"its implementing rules as applied to {entity_name}'s operations "
f"as a Mobile Virtual Network Operator (MVNO) that resells "
f"wireless service provided by {host}."
))
_heading(doc, "2. Scope and Applicability — Division of Responsibility")
_body(doc, (
f"{entity_name} does not own or operate radio-access equipment, "
f"a Mobile Switching Center, an EPC / 5GC, or HSS / UDM. Under "
f"the CALEA Reference Model and the MVNO wholesale agreement "
f"between {entity_name} and {host}, intercept capability is "
f"provisioned and operated by {host}. {entity_name}'s SSI "
f"responsibility is limited to: (i) maintaining a designated "
f"24-hour law-enforcement point of contact, (ii) coordinating "
f"service of process between law enforcement and {host}, "
f"(iii) ensuring contractual flow-down of CALEA obligations to "
f"{host}, and (iv) protecting its own customer records."
))
_heading(doc, "3. Designated Law Enforcement Contact (24-hour)")
_body(doc, (
f"Per 47 CFR \u00a7 1.20003(a)(1), {entity_name} designates the "
f"following senior officer as 24-hour contact for law enforcement."
))
_bullets(doc, [
f"Name: {le.get('name') or '[TO BE POPULATED]'}",
f"Title: {le.get('title') or ''}",
f"Phone (24-hour): {le.get('phone') or ''}",
f"Email (24-hour): {le.get('email_24h') or ''}",
f"Backup contact: {le.get('backup_name') or '[TO BE POPULATED]'}",
])
_body(doc, (
f"Upon receipt of a valid court order, {entity_name}'s designated "
f"officer (a) acknowledges service within two (2) business hours, "
f"(b) confirms that the subscriber is provisioned on {host}'s "
f"network, and (c) coordinates with {host}'s CALEA / LAES team "
f"to effect intercept provisioning, providing the ordering "
f"agency with the appropriate host-MNO CALEA contact as "
f"required."
))
_heading(doc, "4. Network / Interception Capability (Host MNO)")
_body(doc, network_infrastructure_summary or (
f"Voice, SMS, and data services consumed by {entity_name}'s "
f"subscribers traverse {host}'s radio-access network and core. "
f"Authentication is performed against {host}'s HSS / UDM."
))
_body(doc, interception_support_method or (
f"Lawful intercept is provisioned by {host} using the standardized "
f"3GPP LI interfaces (X1 / X2 / X3) at {host}'s MSC / EPC / 5GC, "
f"per ATIS T1.724 / J-STD-025 and 3GPP TS 33.126 / 33.127 / "
f"33.128. {host} is responsible for delivering content and "
f"call-identifying information to the requesting law-enforcement "
f"agency."
))
_body(doc, (
f"{entity_name} retains an executed copy of the MVNO wholesale "
f"agreement with {host}, including the CALEA flow-down clauses, "
f"and a current copy of {host}'s CALEA attestation on file."
))
_heading(doc, "5. CPNI Safeguards")
_body(doc, (
f"{entity_name} maintains separate CPNI procedures under 47 CFR "
f"\u00a7\u00a7 64.2001\u201364.2011 with respect to retail "
f"customer records, billing data, and support interactions that "
f"{entity_name} directly controls. The CPNI Protection Officer is:"
))
_bullets(doc, [
f"Name: {cpni.get('name') or '[TO BE POPULATED]'}",
f"Title: {cpni.get('title') or 'CPNI Protection Officer'}",
])
_heading(doc, "6. Personnel Vetting and Training")
_bullets(doc, [
"Annual CALEA + CPNI training for personnel handling customer "
"records or law-enforcement service of process.",
"Background checks prior to granting access.",
"Access revoked within 24 hours of termination.",
"All service-of-process and CPNI actions attributed to named "
"authenticated users.",
])
_heading(doc, "7. Supervisory Review")
_body(doc, (
f"The {le.get('title') or 'Designated Senior Officer'} reviews "
f"service-of-process logs and MVNO-host coordination records at "
f"least quarterly."
))
_heading(doc, "8. Records Retention")
_body(doc, (
"Service-of-process coordination records retained ten (10) years "
"per 47 CFR \u00a7 1.20003(b); CPNI access logs retained at least "
"two (2) years per 47 CFR \u00a7 64.2009."
))
_heading(doc, "9. Annual Review")
_body(doc, (
f"This Plan is reviewed at least annually and upon any change to "
f"the MVNO wholesale agreement or to {host}'s CALEA attestation. "
f"Next scheduled review: {next_review}."
))
_heading(doc, "10. Certification")
_body(doc, (
f"I, {signatory_name or '[Authorized Officer]'}, as "
f"{signatory_title} of {entity_name}, certify that I have "
f"reviewed this SSI Plan and that {entity_name} complies with 47 "
f"U.S.C. \u00a7 229 and 47 CFR \u00a7 1.20003 through its MVNO "
f"wholesale arrangement with {host}."
))
_body(doc, "")
doc.add_paragraph("_" * 45)
_body(doc, signatory_name or "[Authorized Officer]", bold=True)
_body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}")
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CALEA Wireless MVNO SSI plan generated: %s", out)
return str(out)

View file

@ -0,0 +1,270 @@
"""Traffic Study generator — PDF + XLSX deliverable.
Takes a fully-rolled ``cdr_traffic_studies`` row and produces:
* a signed-ready DOCX (converted to PDF downstream) for the customer's
audit file, with methodology statement + both Block 5 regional
tables + revenue-vs-minutes cross-check
* an XLSX "working doc" with per-period rollups and the same cells
that will drop into the 499-A E-File session
Produced by ``CDRAnalysisHandler`` at the end of a reporting period.
Pre-existing infrastructure reused:
* python-docx for the DOCX
* openpyxl for the XLSX
* scripts.document_gen.templates.base_handler pattern for styling
No classification happens here this module only formats numbers that
the ingester + classifier already wrote into cdr_calls + cdr_traffic_studies.
"""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cdr_traffic_study")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — traffic study generation unavailable")
Document = None # type: ignore[assignment,misc]
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Border, Side
except ImportError:
LOG.warning("openpyxl not installed — xlsx export unavailable")
Workbook = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
def _pct(value) -> str:
if value is None:
return ""
return f"{float(value):.2f}%"
def _dollars(cents: Optional[int]) -> str:
if cents is None:
return ""
return f"${cents/100:,.2f}"
def _minutes(seconds: Optional[int]) -> str:
if seconds is None:
return ""
return f"{seconds/60:,.0f}"
# ─── DOCX ───────────────────────────────────────────────────────────────
def generate_traffic_study_docx(
*,
study: dict,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
output_path: str,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
# Title
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = title.add_run(f"Telecommunications Traffic Study — {study['reporting_year']} {study['reporting_period']}")
r.font.size = Pt(14)
r.bold = True
r.font.color.rgb = NAVY
sub = doc.add_paragraph()
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub_r = sub.add_run(entity_name)
sub_r.font.size = Pt(12)
sub_r.bold = True
info = doc.add_paragraph()
info_r = info.add_run(
f"FRN: {frn or 'N/A'} | 499 Filer ID: {filer_id_499 or 'N/A'} | "
f"Generated: {datetime.now().strftime('%B %d, %Y')}"
)
info_r.font.size = Pt(9)
info_r.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
doc.add_paragraph()
# Methodology
doc.add_heading("Methodology", level=1)
method_paragraphs = [
(
f"This study analyzes {study.get('total_calls', 0):,} call detail records "
f"covering {study['reporting_year']} {study['reporting_period']}. "
"Each call was classified by endpoint geography using NANP area-code "
"records and FCC country-code assignments (47 CFR § 54.706 definitions). "
"Jurisdictional buckets are: interstate, intrastate, international, and "
"indeterminate (records where one or both endpoints could not be "
"resolved to a country/state)."
),
(
"Revenue-based weighting is used where the source CDR carries per-call "
"billing amounts. Minutes-weighted percentages are provided as a "
"cross-check. Records are five-year retained per 47 CFR § 54.711(a) "
"and available for USAC audit on request."
),
(study.get("methodology") or ""),
]
for text in method_paragraphs:
if text:
doc.add_paragraph(text)
# Jurisdictional table
doc.add_heading("Jurisdictional Breakdown", level=1)
juris_table = doc.add_table(rows=1, cols=3)
juris_table.style = "Table Grid"
hdr = juris_table.rows[0].cells
hdr[0].text = "Category"
hdr[1].text = "Revenue-weighted"
hdr[2].text = "Minutes-weighted"
for label, key_rev, key_min in [
("Interstate", "interstate_pct", "interstate_pct_minutes"),
("Intrastate", "intrastate_pct", "intrastate_pct_minutes"),
("International", "international_pct", "international_pct_minutes"),
("Indeterminate", "indeterminate_pct", "indeterminate_pct_minutes"),
]:
row = juris_table.add_row().cells
row[0].text = label
row[1].text = _pct(study.get(key_rev))
row[2].text = _pct(study.get(key_min))
# Wholesale vs retail
doc.add_heading("Block 3 vs. Block 4-A Allocation", level=1)
w_min = study.get("wholesale_minutes") or 0
r_min = study.get("retail_minutes") or 0
doc.add_paragraph(
f"Wholesale (carrier-to-carrier, Block 3): {w_min/60:,.0f} minutes\n"
f"Retail (end-user, Block 4-A): {r_min/60:,.0f} minutes"
)
# Block 5 regional — BOTH reports
for label, key in [
("Block 5 — by originating state of caller", "orig_state_regions_json"),
("Block 5 — by customer billing-address state", "billing_state_regions_json"),
]:
doc.add_heading(label, level=1)
regions = (study.get(key) or {})
if not regions:
doc.add_paragraph("(no data for this view)")
continue
table = doc.add_table(rows=1, cols=2)
table.style = "Table Grid"
h = table.rows[0].cells
h[0].text = "Region"
h[1].text = "% of Total"
for region_name, pct_val in sorted(regions.items()):
row = table.add_row().cells
row[0].text = region_name
row[1].text = _pct(pct_val)
doc.add_heading("Certification", level=1)
doc.add_paragraph(
f"I certify that this traffic study accurately reflects the "
f"telecommunications usage of {entity_name} during the reporting "
f"period. The underlying CDRs are retained for five years and "
f"available on request."
)
for _ in range(2):
doc.add_paragraph()
doc.add_paragraph("_" * 45)
doc.add_paragraph("Authorized Officer")
doc.add_paragraph(entity_name)
doc.add_paragraph(f"Date: {datetime.now().strftime('%B %d, %Y')}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
return str(out)
# ─── XLSX (admin working doc) ───────────────────────────────────────────
def generate_traffic_study_xlsx(
*,
study: dict,
entity_name: str,
output_path: str,
) -> Optional[str]:
if Workbook is None:
LOG.error("openpyxl not installed")
return None
wb = Workbook()
default = wb.active
wb.remove(default)
# Summary
ws = wb.create_sheet("Summary")
ws["A1"] = f"Traffic Study — {entity_name}"
ws["A1"].font = Font(bold=True, size=14, color="1A2744")
ws["A2"] = f"{study['reporting_year']} {study['reporting_period']}"
rows = [
("Total calls", study.get("total_calls") or 0),
("Total minutes", (study.get("total_minutes") or 0)),
("Total revenue (cents)", study.get("total_revenue_cents") or 0),
("", ""),
("Interstate % (revenue-weighted)", study.get("interstate_pct")),
("Intrastate % (revenue-weighted)", study.get("intrastate_pct")),
("International % (revenue-weighted)", study.get("international_pct")),
("Indeterminate % (revenue-weighted)", study.get("indeterminate_pct")),
("", ""),
("Interstate % (minutes-weighted)", study.get("interstate_pct_minutes")),
("Intrastate % (minutes-weighted)", study.get("intrastate_pct_minutes")),
("International % (minutes-weighted)", study.get("international_pct_minutes")),
("Indeterminate % (minutes-weighted)", study.get("indeterminate_pct_minutes")),
("", ""),
("Wholesale minutes (Block 3)", (study.get("wholesale_minutes") or 0) / 60),
("Retail minutes (Block 4-A)", (study.get("retail_minutes") or 0) / 60),
]
for i, (label, value) in enumerate(rows, start=4):
ws.cell(row=i, column=1, value=label)
ws.cell(row=i, column=2, value=value)
ws.column_dimensions["A"].width = 45
ws.column_dimensions["B"].width = 22
# Regional breakdowns
for sheet_name, key in [
("Block 5 — Orig State", "orig_state_regions_json"),
("Block 5 — Billing State", "billing_state_regions_json"),
]:
rs = wb.create_sheet(sheet_name)
rs.cell(row=1, column=1, value="Region").font = Font(bold=True)
rs.cell(row=1, column=2, value="% of Total").font = Font(bold=True)
regions = study.get(key) or {}
for i, (name, pct) in enumerate(sorted(regions.items()), start=2):
rs.cell(row=i, column=1, value=name)
rs.cell(row=i, column=2, value=float(pct) if pct is not None else None)
rs.cell(row=i, column=2).number_format = '0.00"%"'
rs.column_dimensions["A"].width = 25
rs.column_dimensions["B"].width = 15
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
wb.save(str(out))
return str(out)

View file

@ -0,0 +1,263 @@
"""
Generate the FCC CPNI Annual Certification Letter Audio Bridging variant.
Audio bridging / conferencing providers have a narrower CPNI scope than
ordinary carriers: CPNI is generally limited to participant dial-in
records, scheduled conference metadata, and enterprise billing records.
Per the Commission's longstanding treatment of non-real-time conferencing
services, some categories of conference metadata may fall outside the
definition of CPNI where the service is not "telecommunications service"
as defined in 47 USC § 153(53).
This certification addresses the CPNI {entity_name} does hold and states
expressly where non-real-time or information-service exceptions apply.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_audio_bridge")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI Audio Bridge generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "audio_bridging"
VARIANT_LABEL = "Audio Bridging / Conferencing"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def _cb(doc, text, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
r = p.add_run(f" {mark} {text}"); r.font.size = Pt(10)
_sp(p, after=3)
def generate_cpni_audio_bridge(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Audio Bridging / Conferencing \u2014 47 CFR \u00a7 64.2009 "
f"\u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
_h(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein, including procedures "
f"governing participant dial-in records, scheduled-conference "
f"metadata, and enterprise billing data."
))
_h(doc, "3. Scope Note and Certification of Compliance")
_b(doc, (
f"{entity_name} provides audio bridging / conferencing service. "
f"Its CPNI-like holdings are narrow: participant dial-in numbers, "
f"conference-bridge access records, and enterprise billing data. "
f"To the extent any portion of the service is properly classified "
f"as a non-real-time information service rather than a "
f"telecommunications service under 47 USC \u00a7 153(53), the "
f"Commission has recognized that such portion is not subject to "
f"47 CFR Part 64 Subpart U. {entity_name} certifies compliance "
f"with 47 CFR \u00a7\u00a7 64.2001 through 64.2011 with respect to "
f"all remaining CPNI it holds for the period January 1, "
f"{reporting_year} through December 31, {reporting_year}."
))
_h(doc, "4. How Our Procedures Ensure Compliance")
_cb(doc, (
"Access to participant dial-in records and conference metadata is "
"restricted to authenticated personnel performing billing, "
"support, or abuse investigations. Authentication occurs through "
"named-user credentials; access is logged (47 CFR \u00a7 64.2009)."
))
_cb(doc, (
"Customer authentication is required before CPNI release in "
"response to a customer-initiated inquiry. Consumer-side "
"authentication is by pre-established password; enterprise-side "
"authentication is via credentials assigned in the master service "
"agreement (47 CFR \u00a7 64.2010)."
))
_cb(doc, (
"Customer approval for use of CPNI beyond the scope of the "
"subscribed service is obtained through written opt-in consent, "
"documented per 47 CFR \u00a7 64.2007."
))
_cb(doc, (
"Supervisory review of CPNI access is conducted at least "
"quarterly; retention of logs meets or exceeds two years, and "
"certification records are retained for five years, per 47 CFR "
"\u00a7 64.2009."
))
_cb(doc, (
"Annual training is provided to all personnel with CPNI access; "
"breach-notification procedures comply with 47 CFR \u00a7 64.2011 "
"as amended by FCC 23-111."
))
_h(doc, "5. Customer Complaints")
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or "Each complaint was investigated and resolved."
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_h(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI."
))
else:
desc = data_broker_description or "Each was refused, documented, and escalated."
_b(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_h(doc, "7. Breach Log Summary")
if not breaches:
_b(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications "
f"were required."
))
else:
_b(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported within 7 business days."
))
_h(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} and the undersigned acknowledge that CPNI rule "
f"violations may subject the carrier to forfeitures up to "
f"{MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act."
))
_b(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that no "
"material factual information has been withheld and all statements "
"are truthful, accurate, and complete."
))
_b(doc, (
"Willful false statements are punishable under Title 18, U.S.C. "
"\u00a7 1001, and by forfeiture under 47 U.S.C. \u00a7 503."
))
_h(doc, "9. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
p = doc.add_paragraph(); _sp(p, after=0)
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10); _sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph(); tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI Audio Bridge certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,588 @@
"""
Generate the FCC CPNI Annual Certification Letter.
Produces the annual certification required by 47 CFR § 64.2009 certifying
compliance with the Customer Proprietary Network Information (CPNI) rules
(47 CFR §§ 64.2001-64.2011), including amendments from the 2023 Data Breach
Notification Order (FCC 23-111).
The letter is largely standard across carrier types. The only variation
is wholesale-only carriers, whose CPNI obligations are limited to wholesale
customer proprietary data rather than retail end-user CPNI.
Usage:
from scripts.document_gen.templates.cpni_cert_letter_generator import (
generate_cpni_cert_letter,
)
path = generate_cpni_cert_letter(
entity_name="Falcon Broadband LLC",
frn="0027160886",
filer_id_499="812345",
reporting_year=2025,
complaints_count=0,
output_path="/tmp/cpni_cert.docx",
)
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_cert")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
except ImportError:
LOG.warning("python-docx not installed — CPNI cert letter generation unavailable")
Document = None # type: ignore[assignment,misc]
# Navy blue used for section headings (RGB 0x1A, 0x27, 0x44)
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
# Spacing constants (in twips; 1 pt = 20 twips)
_AFTER_6PT = Pt(6) if Document else None
def generate_cpni_cert_letter(
# ── Entity identity ───────────────────────────────────────────
entity_name: str,
frn: str = "",
filer_id_499: str = "",
# ── Address ───────────────────────────────────────────────────
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
# ── Contact / officer ─────────────────────────────────────────
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
contact_email: str = "",
contact_phone: str = "",
# ── Reporting ─────────────────────────────────────────────────
reporting_year: int = 0,
complaints_count: int = 0,
complaints_description: str = "",
# ── Carrier flags ─────────────────────────────────────────────
is_wholesale: bool = False,
# ── Employee training ─────────────────────────────────────────
employee_training_conducted: bool = True,
# ── Disciplinary actions ──────────────────────────────────────
disciplinary_actions_taken: bool = False,
disciplinary_actions_description: str = "",
# ── Data broker actions ───────────────────────────────────────
data_broker_actions: str = "",
# ── Breaches (per FCC 23-111) ─────────────────────────────────
breaches: list[dict] | None = None,
# ── Marketing / CPNI usage ────────────────────────────────────
uses_cpni_for_marketing: bool = False,
cpni_approval_method: str = "opt_in", # "opt_in" or "opt_out"
# ── Pretexting safeguards ─────────────────────────────────────
pretexting_safeguards: str = "",
# ── Output ────────────────────────────────────────────────────
output_path: str = "/tmp/cpni_certification_letter.docx",
) -> Optional[str]:
"""
Generate a CPNI Annual Certification Letter as a DOCX file.
Compliant with 47 CFR § 64.2009, including the 2023 Data Breach
Notification Order (FCC 23-111).
Returns the output file path on success, None on failure.
"""
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
if breaches is None:
breaches = []
doc = Document()
# ── Page setup ────────────────────────────────────────────────
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
cpni_scope = (
"wholesale customer proprietary data"
if is_wholesale
else "customer proprietary network information (CPNI)"
)
# ── Helper functions ──────────────────────────────────────────
def _set_spacing(paragraph, after_pt=6, before_pt=0):
"""Set paragraph spacing in points."""
pf = paragraph.paragraph_format
pf.space_after = Pt(after_pt)
if before_pt:
pf.space_before = Pt(before_pt)
def _heading(text: str, level: int = 1) -> None:
"""Add a navy blue section heading."""
p = doc.add_paragraph()
run = p.add_run(text)
run.font.size = Pt(12)
run.bold = True
run.font.color.rgb = _NAVY
_set_spacing(p, after_pt=4, before_pt=8)
def _body(text: str, bold: bool = False, size: int = 10) -> None:
"""Add body-text paragraph with 6pt spacing after."""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run(text)
run.font.size = Pt(size)
run.bold = bold
_set_spacing(p, after_pt=6)
def _checkbox(label: str, checked: bool = True) -> None:
"""Add a checkbox-style line item."""
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run(f" {mark} {label}")
run.font.size = Pt(10)
_set_spacing(p, after_pt=3)
def _spacer() -> None:
p = doc.add_paragraph()
_set_spacing(p, after_pt=0)
# ── Page numbers ──────────────────────────────────────────────
for section in doc.sections:
footer = section.footer
footer.is_linked_to_previous = False
fp = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Insert PAGE field
run = fp.add_run()
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
fld_char_begin = run._element.makeelement(qn("w:fldChar"), {qn("w:fldCharType"): "begin"})
run._element.append(fld_char_begin)
run2 = fp.add_run()
run2.font.size = Pt(8)
run2.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
instr = run2._element.makeelement(qn("w:instrText"), {})
instr.text = " PAGE "
run2._element.append(instr)
run3 = fp.add_run()
run3.font.size = Pt(8)
fld_char_end = run3._element.makeelement(qn("w:fldChar"), {qn("w:fldCharType"): "end"})
run3._element.append(fld_char_end)
# ══════════════════════════════════════════════════════════════
# TITLE
# ══════════════════════════════════════════════════════════════
title_p = doc.add_paragraph()
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_run = title_p.add_run("CPNI Annual Certification Letter")
title_run.font.size = Pt(14)
title_run.bold = True
title_run.font.color.rgb = _NAVY
_set_spacing(title_p, after_pt=2)
subtitle_p = doc.add_paragraph()
subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub_run = subtitle_p.add_run(
f"Pursuant to 47 CFR \u00a7 64.2009 \u2014 Calendar Year {reporting_year}"
)
sub_run.font.size = Pt(10)
sub_run.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_set_spacing(subtitle_p, after_pt=6)
# Horizontal rule
rule_p = doc.add_paragraph()
rule_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
rule_run = rule_p.add_run("\u2500" * 72)
rule_run.font.size = Pt(6)
rule_run.font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA)
_set_spacing(rule_p, after_pt=8)
# ══════════════════════════════════════════════════════════════
# SECTION 1: Provider Information
# ══════════════════════════════════════════════════════════════
_heading("1. Provider Information")
info_lines = [f"Company Name: {entity_name}"]
if frn:
info_lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499:
info_lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
info_lines.append(f"Address: {addr.strip(', ')}")
if contact_phone:
info_lines.append(f"Telephone: {contact_phone}")
if contact_email:
info_lines.append(f"Email: {contact_email}")
info_lines.append(f"Certifying Officer: {signer}, {title}")
info_lines.append(f"Date of Filing: {today}")
info_lines.append(
f"Filing Deadline: March 1, {reporting_year + 1}"
)
_body("\n".join(info_lines))
# ══════════════════════════════════════════════════════════════
# SECTION 2: Certification of Compliance
# ══════════════════════════════════════════════════════════════
_heading("2. Certification of Compliance")
_body(
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} "
f"({'FRN: ' + frn if frn else 'FRN pending'}"
f"{', Filer ID: ' + filer_id_499 if filer_id_499 else ''}) "
f"hereby submits its annual certification of compliance with the "
f"Commission's Customer Proprietary Network Information (CPNI) rules "
f"for calendar year {reporting_year}."
)
_body(
f"I, {signer}, {title} of {entity_name}, have personal knowledge "
f"of, have reviewed, and am familiar with {entity_name}'s CPNI "
f"compliance procedures and certify that the company has established "
f"operating procedures that ensure compliance with the Commission's "
f"CPNI rules set forth in 47 CFR \u00a7\u00a7 64.2001 through 64.2011. "
f"{entity_name} has taken appropriate actions to protect the "
f"confidentiality of {cpni_scope} and has limited access to and use "
f"of such information in accordance with the Commission's rules."
)
# ══════════════════════════════════════════════════════════════
# SECTION 3: Reporting Period
# ══════════════════════════════════════════════════════════════
_heading("3. Reporting Period")
_body(
f"This certification covers the period from January 1, {reporting_year} "
f"through December 31, {reporting_year}."
)
# ══════════════════════════════════════════════════════════════
# SECTION 4: CPNI Safeguards
# ══════════════════════════════════════════════════════════════
_heading("4. CPNI Safeguards")
_body(
f"{entity_name} has implemented the following safeguards to protect "
f"{cpni_scope}:"
)
# 4a - Customer authentication
_body("(a) Customer Authentication and Password Procedures", bold=True)
_checkbox(
f"{entity_name} requires customer authentication through a password "
f"or other secure credential before disclosing CPNI in response to "
f"customer-initiated contacts, in accordance with 47 CFR \u00a7 64.2010.",
checked=True,
)
# 4b - Employee training
_body("(b) Employee Training", bold=True)
_checkbox(
f"All employees with access to CPNI have been adequately trained on "
f"the Commission's CPNI rules, including proper handling, disclosure "
f"limitations, and breach notification procedures.",
checked=employee_training_conducted,
)
if not employee_training_conducted:
_body(
f"NOTE: {entity_name} is in the process of completing employee "
f"training and anticipates full compliance within 30 days of this "
f"filing."
)
# 4c - Supervisory review
_body("(c) Supervisory Review", bold=True)
_checkbox(
f"{entity_name} conducts regular supervisory reviews of CPNI access "
f"and usage to ensure compliance with established procedures.",
checked=True,
)
# 4d - Pretexting safeguards
_body("(d) Pretexting Safeguards", bold=True)
if pretexting_safeguards:
_checkbox(pretexting_safeguards, checked=True)
else:
_checkbox(
f"{entity_name} has implemented safeguards to protect against "
f"pretexting, including customer identity verification protocols, "
f"employee awareness training on social engineering tactics, and "
f"procedures to detect and report suspected pretexting attempts.",
checked=True,
)
# 4e - Notification of account changes
_body("(e) Notification of Account Changes", bold=True)
_checkbox(
f"{entity_name} notifies customers of account changes, including "
f"changes to passwords, address of record, or online account "
f"credentials, through a communication to the customer's address "
f"of record or established backup contact method, in accordance "
f"with 47 CFR \u00a7 64.2010.",
checked=True,
)
# 4f - Record retention
_body("(f) Record Retention", bold=True)
_checkbox(
f"{entity_name} maintains records of all CPNI access, disclosures, "
f"customer complaints, and compliance actions for a minimum period "
f"of five (5) years, as required by 47 CFR \u00a7 64.2009(e).",
checked=True,
)
# ══════════════════════════════════════════════════════════════
# SECTION 5: CPNI Complaints
# ══════════════════════════════════════════════════════════════
_heading("5. CPNI Complaints")
if complaints_count == 0:
_body(
f"During the reporting period, {entity_name} received no complaints "
f"regarding unauthorized release or use of CPNI."
)
else:
desc = complaints_description or (
f"Each complaint was investigated and resolved in accordance with "
f"{entity_name}'s CPNI compliance procedures."
)
_body(
f"During the reporting period, {entity_name} received "
f"{complaints_count} complaint{'s' if complaints_count != 1 else ''} "
f"regarding CPNI. {desc}"
)
# ══════════════════════════════════════════════════════════════
# SECTION 6: Data Breaches
# ══════════════════════════════════════════════════════════════
_heading("6. Data Breaches")
if not breaches:
_body(
f"During the reporting period, {entity_name} experienced no data "
f"breaches involving CPNI. No breach notifications were required "
f"to be filed with the Commission, law enforcement, or affected "
f"customers under 47 CFR \u00a7 64.2011."
)
else:
total_breaches = len(breaches)
total_affected = sum(b.get("customers_affected", 0) for b in breaches)
_body(
f"During the reporting period, {entity_name} experienced "
f"{total_breaches} data breach{'es' if total_breaches != 1 else ''} "
f"involving CPNI, affecting a total of {total_affected:,} "
f"customer{'s' if total_affected != 1 else ''}. Details of each "
f"breach are provided below."
)
# Breach detail table
table = doc.add_table(rows=1, cols=5)
table.style = "Table Grid"
# Header row
headers = [
"Breach #", "Date", "Customers\nAffected",
"Description", "Response Actions",
]
hdr_cells = table.rows[0].cells
for i, header in enumerate(headers):
hdr_cells[i].text = ""
p = hdr_cells[i].paragraphs[0]
run = p.add_run(header)
run.bold = True
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# Navy background
shading = hdr_cells[i]._element.makeelement(
qn("w:shd"),
{
qn("w:val"): "clear",
qn("w:color"): "auto",
qn("w:fill"): "1A2744",
},
)
tc_pr = hdr_cells[i]._element.get_or_add_tcPr()
tc_pr.append(shading)
# Data rows
for idx, breach in enumerate(breaches, start=1):
row_cells = table.add_row().cells
values = [
str(idx),
str(breach.get("date", "N/A")),
f"{breach.get('customers_affected', 0):,}",
str(breach.get("description", "")),
str(breach.get("response_actions", "")),
]
for i, val in enumerate(values):
row_cells[i].text = ""
p = row_cells[i].paragraphs[0]
run = p.add_run(val)
run.font.size = Pt(9)
_spacer()
# ══════════════════════════════════════════════════════════════
# SECTION 7: Disciplinary Actions
# ══════════════════════════════════════════════════════════════
_heading("7. Disciplinary Actions")
if not disciplinary_actions_taken:
_body(
f"During the reporting period, {entity_name} did not take any "
f"disciplinary action against employees for violations of the "
f"Commission's CPNI rules."
)
else:
desc = disciplinary_actions_description or (
"Disciplinary action was taken in accordance with company policy."
)
_body(
f"During the reporting period, {entity_name} took disciplinary "
f"action against one or more employees for violations of the "
f"Commission's CPNI rules. {desc}"
)
# ══════════════════════════════════════════════════════════════
# SECTION 8: Data Broker Actions
# ══════════════════════════════════════════════════════════════
_heading("8. Actions Taken Against Data Brokers")
if data_broker_actions:
_body(
f"During the reporting period, {entity_name} took the following "
f"actions against data brokers: {data_broker_actions}"
)
else:
_body(
f"During the reporting period, {entity_name} did not identify any "
f"data brokers engaging in unauthorized access to or sale of CPNI, "
f"and no actions against data brokers were required."
)
# ══════════════════════════════════════════════════════════════
# SECTION 9: CPNI Marketing Usage
# ══════════════════════════════════════════════════════════════
_heading("9. CPNI Marketing Usage")
if uses_cpni_for_marketing:
method_label = (
"opt-in" if cpni_approval_method == "opt_in" else "opt-out"
)
_body(
f"{entity_name} uses CPNI for marketing purposes. Customer "
f"approval for such use is obtained through the {method_label} "
f"method, in accordance with 47 CFR \u00a7 64.2007."
)
else:
_body(
f"{entity_name} does not use CPNI for marketing purposes beyond "
f"the scope of services to which the customer already subscribes. "
f"No customer approval mechanism is required."
)
# ══════════════════════════════════════════════════════════════
# SECTION 10: Breach Notification Compliance
# ══════════════════════════════════════════════════════════════
_heading("10. Breach Notification Compliance")
_body(
f"{entity_name} certifies that its breach notification procedures "
f"are compliant with 47 CFR \u00a7 64.2011, as amended by the 2023 "
f"Data Breach Notification Order (FCC 23-111). These procedures "
f"include:"
)
_checkbox(
"Notification to the FCC and, where applicable, the FBI and U.S. "
"Secret Service, as soon as practicable and in no event later than "
"30 days after reasonable determination of a breach.",
checked=True,
)
_checkbox(
"Notification to affected customers as soon as practicable and in "
"no event later than 30 days after notification to law enforcement "
"(unless a delay is requested by law enforcement).",
checked=True,
)
_checkbox(
"Breach notifications include the required content specified in "
"\u00a7 64.2011, including a description of the breach, the categories "
"of information compromised, and contact information for inquiries.",
checked=True,
)
# ══════════════════════════════════════════════════════════════
# SECTION 11: Officer Certification & Signature
# ══════════════════════════════════════════════════════════════
_heading("11. Officer Certification and Signature")
_body(
f"I, {signer}, {title} of {entity_name}, certify under penalty of "
f"perjury that the foregoing is true and correct. I have personal "
f"knowledge of the facts stated herein, have reviewed {entity_name}'s "
f"CPNI compliance procedures, and am satisfied that {entity_name} has "
f"complied with the requirements of 47 CFR \u00a7\u00a7 64.2001 through "
f"64.2011 during calendar year {reporting_year}."
)
_spacer()
_body("Respectfully submitted,")
_spacer()
_spacer()
# Signature line
sig_line = doc.add_paragraph()
sig_run = sig_line.add_run("_" * 45)
sig_run.font.size = Pt(10)
_set_spacing(sig_line, after_pt=2)
sig_name_p = doc.add_paragraph()
name_run = sig_name_p.add_run(signer)
name_run.font.size = Pt(10)
name_run.bold = True
_set_spacing(sig_name_p, after_pt=2)
sig_title_p = doc.add_paragraph()
sig_title_p.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_set_spacing(sig_title_p, after_pt=2)
sig_date_p = doc.add_paragraph()
sig_date_p.add_run(f"Date: {today}").font.size = Pt(10)
_set_spacing(sig_date_p, after_pt=2)
if contact_phone:
sig_phone_p = doc.add_paragraph()
sig_phone_p.add_run(f"Telephone: {contact_phone}").font.size = Pt(10)
_set_spacing(sig_phone_p, after_pt=2)
if contact_email:
sig_email_p = doc.add_paragraph()
sig_email_p.add_run(f"Email: {contact_email}").font.size = Pt(10)
_set_spacing(sig_email_p, after_pt=2)
# ── Save ──────────────────────────────────────────────────────
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output))
LOG.info("CPNI certification letter generated: %s", output)
return str(output)

View file

@ -0,0 +1,366 @@
"""
Generate the FCC CPNI Annual Certification Letter CLEC (Facilities) variant.
Tailors the generic CPNI certification under 47 CFR § 64.2009(e) for a
Competitive Local Exchange Carrier operating its own TDM / SS7 switching
plant. Customer authorization for CPNI is obtained through traditional
written / oral opt-in methods; the CPNI Protection Officer's scope of
oversight explicitly includes SS7 / SIGTRAN intercept provisioning and
PIC / LIDB record handling.
2026 amendments included:
* Maximum forfeiture $251,322 per violation (capped $2,513,215).
* 47 CFR § 1.17 truthfulness representation.
* Title 18 penalty acknowledgment.
* Explicit "has / has not" language for customer complaints + data
broker inquiries (Report & Order FCC-25-XXX).
* Officer statement of personal knowledge.
* Narrative "how procedures ensure compliance" section.
Usage:
from scripts.document_gen.templates.cpni_clec_generator import (
generate_cpni_clec,
)
path = generate_cpni_clec(
output_path="/tmp/cpni_clec.docx",
entity_name="Acme Telco LLC",
frn="0027160886",
filer_id_499="812345",
officer_name="Jane Doe",
officer_title="CEO",
reporting_year=2025,
)
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_clec")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
except ImportError:
LOG.warning("python-docx not installed — CPNI CLEC generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "clec"
VARIANT_LABEL = "Competitive Local Exchange Carrier (CLEC)"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _set_spacing(paragraph, after_pt=6, before_pt=0):
pf = paragraph.paragraph_format
pf.space_after = Pt(after_pt)
if before_pt:
pf.space_before = Pt(before_pt)
def _heading(doc, text: str) -> None:
p = doc.add_paragraph()
run = p.add_run(text)
run.font.size = Pt(12)
run.bold = True
run.font.color.rgb = _NAVY
_set_spacing(p, after_pt=4, before_pt=8)
def _body(doc, text: str, bold: bool = False, size: int = 10) -> None:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run(text)
run.font.size = Pt(size)
run.bold = bold
_set_spacing(p, after_pt=6)
def _checkbox(doc, label: str, checked: bool = True) -> None:
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run(f" {mark} {label}")
run.font.size = Pt(10)
_set_spacing(p, after_pt=3)
def _spacer(doc) -> None:
p = doc.add_paragraph()
_set_spacing(p, after_pt=0)
def generate_cpni_clec(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
"""Generate the CLEC (facilities) CPNI Annual Certification Letter."""
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
# ── Title ────────────────────────────────────────────────────────
title_p = doc.add_paragraph()
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
tr = title_p.add_run("CPNI Annual Certification Letter")
tr.font.size = Pt(14)
tr.bold = True
tr.font.color.rgb = _NAVY
_set_spacing(title_p, after_pt=2)
sub_p = doc.add_paragraph()
sub_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub = sub_p.add_run(
f"Competitive Local Exchange Carrier \u2014 "
f"47 CFR \u00a7 64.2009 \u2014 Calendar Year {reporting_year}"
)
sub.font.size = Pt(10)
sub.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_set_spacing(sub_p, after_pt=8)
# ── 1. Provider Information ──────────────────────────────────────
_heading(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn:
lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499:
lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone:
lines.append(f"Telephone: {contact_phone}")
if contact_email:
lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_body(doc, "\n".join(lines))
# ── 2. Officer Statement of Personal Knowledge ───────────────────
_heading(doc, "2. Officer Statement of Personal Knowledge")
_body(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein. I have reviewed "
f"{entity_name}'s CPNI operating procedures, interviewed personnel "
f"responsible for CPNI handling, and examined supervisory logs and "
f"records covering the reporting period. The representations set "
f"forth in this certification are based on my personal review and "
f"are true and correct to the best of my knowledge, information, "
f"and belief."
))
# ── 3. Certification of Compliance ───────────────────────────────
_heading(doc, "3. Certification of Compliance")
_body(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the Customer Proprietary "
f"Network Information (CPNI) rules at 47 CFR \u00a7\u00a7 64.2001 "
f"through 64.2011 for the period January 1, {reporting_year} through "
f"December 31, {reporting_year}. {entity_name} has established, "
f"maintained, and adhered to operating procedures that ensure "
f"compliance with these rules."
))
# ── 4. How Procedures Ensure Compliance (narrative) ──────────────
_heading(doc, "4. How Our Procedures Ensure Compliance")
_body(doc, (
f"As a competitive local exchange carrier operating on circuit-switched "
f"(TDM) switching platforms with SS7 / SIGTRAN signaling, {entity_name} "
f"protects CPNI throughout the complete life-cycle of a customer "
f"relationship. Specific procedures include:"
))
_checkbox(doc, (
"Customer authentication is required before any CPNI disclosure in "
"response to a customer-initiated contact. Authentication is by "
"pre-established password or, for in-store visits, a photo ID plus "
"verification of two account attributes that are not CPNI "
"(47 CFR \u00a7 64.2010)."
))
_checkbox(doc, (
"Customer approval for use of CPNI beyond the scope of the "
"subscribed service is obtained through traditional written or oral "
"opt-in consent, documented in the customer record per 47 CFR "
"\u00a7 64.2007 and \u00a7 64.2008. Oral approvals are date-stamped "
"and time-stamped with the agent's identity."
))
_checkbox(doc, (
"The CPNI Protection Officer's oversight scope expressly includes "
"SS7 / SIGTRAN intercept provisioning, LIDB access, PIC-change "
"verification, and wholesale handoff logs, ensuring that network-"
"element CPNI (e.g., originating-number records, call-path signaling) "
"is governed by the same safeguards as customer-facing systems."
))
_checkbox(doc, (
"PIC and account changes trigger customer notification to the "
"address of record before taking effect, per 47 CFR \u00a7 64.2010(f)."
))
_checkbox(doc, (
"All access to CPNI-bearing systems is logged, with supervisory "
"review at least quarterly. Retention of access logs meets or "
"exceeds two years (CPNI) and five years (certification records) "
"per 47 CFR \u00a7 64.2009."
))
_checkbox(doc, (
"Annual CPNI training is required for all personnel with CPNI "
"access. Completion is tracked and attested to by the CPNI Protection "
"Officer. Disciplinary procedures are documented and applied to any "
"violation."
))
_checkbox(doc, (
"Breach notification under 47 CFR \u00a7 64.2011 is implemented as "
"amended by FCC 23-111 \u2014 notice to the Commission within 7 "
"business days and to customers / law enforcement as soon as "
"practicable, not later than 30 days after reasonable determination."
))
# ── 5. Customer Complaints (has / has not) ───────────────────────
_heading(doc, "5. Customer Complaints")
if complaints_count == 0:
_body(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or (
"Each complaint was investigated and resolved in accordance with "
"the company's CPNI compliance procedures."
)
_body(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period concerning the unauthorized release or use "
f"of CPNI. {desc}"
))
# ── 6. Data Broker Inquiries (has / has not) ─────────────────────
_heading(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_body(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI during the reporting "
f"period."
))
else:
desc = data_broker_description or (
"Each such inquiry was refused, documented, and escalated to "
"the CPNI Protection Officer."
)
_body(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
# ── 7. Breach Log Summary ────────────────────────────────────────
_heading(doc, "7. Breach Log Summary")
if not breaches:
_body(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications were "
f"required."
))
else:
_body(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period. Each was reported to the Commission via the CPNI "
f"Breach Reporting Portal within 7 business days."
))
# ── 8. Penalties and Truthfulness ────────────────────────────────
_heading(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_body(doc, (
f"{entity_name} and the undersigned officer acknowledge that "
f"violations of the CPNI rules may subject the carrier to monetary "
f"forfeitures of up to {MAX_FORFEITURE_PER_VIOLATION} per violation "
f"and up to {MAX_FORFEITURE_CAP} for any single act or failure to "
f"act (adjusted for inflation per 47 CFR \u00a7 1.80)."
))
_body(doc, (
f"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that "
f"no material factual information has been withheld from the "
f"Commission and that all statements herein are truthful, accurate, "
f"and complete to the best of the undersigned's knowledge and "
f"belief, and are not intended to mislead the Commission."
))
_body(doc, (
f"The undersigned further acknowledges that willful false statements "
f"made in this certification are punishable by fine and/or "
f"imprisonment under Title 18, U.S.C. \u00a7 1001, and/or by "
f"forfeiture under 47 U.S.C. \u00a7 503."
))
# ── 9. Signature ─────────────────────────────────────────────────
_heading(doc, "9. Signature of Certifying Officer")
_body(doc, (
f"I declare under penalty of perjury under the laws of the "
f"United States of America that the foregoing is true and correct."
))
_spacer(doc)
sig = doc.add_paragraph()
sig.add_run("_" * 45).font.size = Pt(10)
_set_spacing(sig, after_pt=2)
nm = doc.add_paragraph()
nr = nm.add_run(signer)
nr.bold = True
nr.font.size = Pt(10)
_set_spacing(nm, after_pt=2)
tp = doc.add_paragraph()
tp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_set_spacing(tp, after_pt=2)
dp = doc.add_paragraph()
dp.add_run(f"Date: {today}").font.size = Pt(10)
_set_spacing(dp, after_pt=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI CLEC certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,307 @@
"""
Generate the FCC CPNI Annual Certification Letter CLEC Reseller variant.
Customer Approval / Safeguards language adapted for a CLEC reseller that
purchases local exchange capacity wholesale and resells it under its own
brand. The reseller's CPNI obligations extend both to end-user CPNI it
directly handles AND to CPNI that flows down from the wholesale provider.
Safeguards therefore include contractual flow-down terms.
See module ``cpni_clec_generator`` for the shared 2026 statutory block.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_clec_reseller")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI CLEC-Reseller generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "clec_reseller"
VARIANT_LABEL = "Competitive Local Exchange Carrier — Reseller"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _set_spacing(paragraph, after_pt=6, before_pt=0):
pf = paragraph.paragraph_format
pf.space_after = Pt(after_pt)
if before_pt:
pf.space_before = Pt(before_pt)
def _heading(doc, text):
p = doc.add_paragraph()
r = p.add_run(text)
r.font.size = Pt(12)
r.bold = True
r.font.color.rgb = _NAVY
_set_spacing(p, after_pt=4, before_pt=8)
def _body(doc, text, bold=False, size=10):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text)
r.font.size = Pt(size)
r.bold = bold
_set_spacing(p, after_pt=6)
def _checkbox(doc, label, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(f" {mark} {label}")
r.font.size = Pt(10)
_set_spacing(p, after_pt=3)
def _spacer(doc):
p = doc.add_paragraph()
_set_spacing(p, after_pt=0)
def generate_cpni_clec_reseller(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
upstream_wholesale_provider: str = "",
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
upstream = upstream_wholesale_provider or "its wholesale underlying carrier(s)"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_set_spacing(tp, after_pt=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Competitive Local Exchange Carrier — Reseller \u2014 "
f"47 CFR \u00a7 64.2009 \u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_set_spacing(sp, after_pt=8)
_heading(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn:
lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499:
lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone:
lines.append(f"Telephone: {contact_phone}")
if contact_email:
lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_body(doc, "\n".join(lines))
_heading(doc, "2. Officer Statement of Personal Knowledge")
_body(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein. I have reviewed "
f"{entity_name}'s CPNI operating procedures (including reseller "
f"flow-down terms with upstream wholesale providers), and I have "
f"examined supervisory logs and records covering the reporting "
f"period. The representations herein are based on my personal "
f"review and are true and correct to the best of my knowledge, "
f"information, and belief."
))
_heading(doc, "3. Certification of Compliance")
_body(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the CPNI rules at "
f"47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}. "
f"{entity_name} has established, maintained, and adhered to "
f"operating procedures that ensure compliance with these rules."
))
_heading(doc, "4. How Our Procedures Ensure Compliance")
_body(doc, (
f"As a CLEC reseller purchasing wholesale local-exchange capacity "
f"from {upstream} and reselling it under its own brand, {entity_name} "
f"protects CPNI at two boundaries: (1) the retail end-user interface "
f"where it directly handles customer records, and (2) the wholesale "
f"flow from the underlying carrier(s). Specific procedures include:"
))
_checkbox(doc, (
"End-user customer authentication is required before any CPNI "
"disclosure; authentication uses pre-established password or "
"verification of non-CPNI account attributes (47 CFR \u00a7 64.2010)."
))
_checkbox(doc, (
"CPNI use beyond the scope of the subscribed service is permitted "
"only after written or oral opt-in consent under 47 CFR \u00a7 64.2007, "
"documented in the customer record."
))
_checkbox(doc, (
f"Reseller flow-down: {entity_name}'s wholesale-service agreement "
f"with {upstream} expressly requires the upstream carrier to treat "
f"all end-user CPNI received through {entity_name} in a manner "
f"consistent with 47 CFR \u00a7\u00a7 64.2001\u201364.2011. "
f"{entity_name} reviews upstream CPNI attestations annually."
))
_checkbox(doc, (
"The CPNI Protection Officer has oversight authority over both "
"retail records systems and wholesale interconnect logs, and "
"reviews upstream carrier breach notices per 47 CFR \u00a7 64.2011."
))
_checkbox(doc, (
"Changes to an end-user account (PIC, address-of-record, password) "
"are confirmed to the customer's address of record before taking "
"effect."
))
_checkbox(doc, (
"Access logs are maintained and reviewed at least quarterly; "
"retention meets or exceeds two years for CPNI access and five "
"years for certification records under 47 CFR \u00a7 64.2009."
))
_checkbox(doc, (
"Annual CPNI training is conducted for all personnel with CPNI "
"access. Disciplinary procedures are documented and applied to "
"any violation."
))
_heading(doc, "5. Customer Complaints")
if complaints_count == 0:
_body(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or (
"Each complaint was investigated and resolved in accordance with "
"the company's CPNI compliance procedures."
)
_body(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_heading(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_body(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI."
))
else:
desc = data_broker_description or (
"Each such inquiry was refused, documented, and escalated."
)
_body(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_heading(doc, "7. Breach Log Summary")
if not breaches:
_body(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications were "
f"required."
))
else:
_body(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported to the Commission via the CPNI "
f"Breach Reporting Portal within 7 business days."
))
_heading(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_body(doc, (
f"{entity_name} and the undersigned officer acknowledge that CPNI "
f"rule violations may subject the carrier to monetary forfeitures "
f"of up to {MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act "
f"(adjusted per 47 CFR \u00a7 1.80)."
))
_body(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that "
"no material factual information has been withheld and that all "
"statements herein are truthful, accurate, and complete."
))
_body(doc, (
"The undersigned acknowledges that willful false statements in this "
"certification are punishable under Title 18, U.S.C. \u00a7 1001, "
"and by forfeiture under 47 U.S.C. \u00a7 503."
))
_heading(doc, "9. Signature of Certifying Officer")
_body(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
_spacer(doc)
sig = doc.add_paragraph()
sig.add_run("_" * 45).font.size = Pt(10)
_set_spacing(sig, after_pt=2)
nm = doc.add_paragraph()
nr = nm.add_run(signer); nr.bold = True; nr.font.size = Pt(10)
_set_spacing(nm, after_pt=2)
tpp = doc.add_paragraph()
tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_set_spacing(tpp, after_pt=2)
dp = doc.add_paragraph()
dp.add_run(f"Date: {today}").font.size = Pt(10)
_set_spacing(dp, after_pt=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI CLEC-Reseller certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,289 @@
"""
Generate the FCC CPNI Annual Certification Letter IXC (Facilities) variant.
Tailors the generic CPNI certification under 47 CFR § 64.2009(e) for an
Interexchange Carrier (IXC) focused on toll-call record handling. Key
variant differences:
* CPNI scope centered on toll call records, PIC-change verification,
and interexchange account authentication.
* Customer approval for CPNI usage follows written/oral opt-in,
documented in the toll-account record.
* The CPNI Protection Officer's duties include PIC / LIDB / CDR
access governance and fraud-management system controls.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_ixc")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI IXC generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "ixc"
VARIANT_LABEL = "Interexchange Carrier (IXC)"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph()
r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def _cb(doc, text, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
r = p.add_run(f" {mark} {text}")
r.font.size = Pt(10)
_sp(p, after=3)
def generate_cpni_ixc(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Interexchange Carrier \u2014 47 CFR \u00a7 64.2009 "
f"\u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
_h(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein. I have reviewed "
f"{entity_name}'s CPNI operating procedures, including procedures "
f"governing toll call records, PIC-change verification, and "
f"interexchange account authentication. I have examined supervisory "
f"logs and records covering the reporting period. The "
f"representations herein are based on my personal review and are "
f"true and correct to the best of my knowledge, information, and "
f"belief."
))
_h(doc, "3. Certification of Compliance")
_b(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the CPNI rules at "
f"47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}."
))
_h(doc, "4. How Our Procedures Ensure Compliance")
_b(doc, (
f"As an interexchange carrier, {entity_name}'s principal CPNI "
f"holdings are toll call detail records (CDRs), PIC-change records, "
f"and inter-carrier settlement data. Specific procedures include:"
))
_cb(doc, (
"Interexchange account authentication is required before release "
"of any toll record. Authentication is via pre-established "
"password or the verification of two non-CPNI account attributes "
"(47 CFR \u00a7 64.2010)."
))
_cb(doc, (
"PIC changes require affirmative verification under 47 CFR "
"\u00a7 64.1120 (Third Party Verification, Letter of Agency, or "
"Internet LOA) and are confirmed to the customer's address of "
"record before being implemented. Slamming-prevention controls "
"are integrated with CPNI access logging."
))
_cb(doc, (
"Customer approval for use of toll CPNI beyond the scope of the "
"subscribed service is obtained through written or oral opt-in "
"consent, documented in the account record per 47 CFR \u00a7 64.2007."
))
_cb(doc, (
"The CPNI Protection Officer has oversight authority over PIC "
"administration, LIDB access, CDR archives, and fraud-management "
"systems. Access attempts to these systems are logged to the named "
"individual."
))
_cb(doc, (
"Supervisory reviews of CPNI access are conducted at least "
"quarterly. Retention of access logs meets or exceeds two years "
"(CPNI) and five years (certification records) per 47 CFR "
"\u00a7 64.2009."
))
_cb(doc, (
"Annual CPNI training is required for all personnel with CPNI "
"access. Completion is tracked and attested to by the CPNI "
"Protection Officer."
))
_cb(doc, (
"Breach notification under 47 CFR \u00a7 64.2011 is implemented as "
"amended by FCC 23-111 \u2014 notice to the Commission within 7 "
"business days and to customers / law enforcement as soon as "
"practicable (not later than 30 days after reasonable determination)."
))
_h(doc, "5. Customer Complaints")
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or (
"Each complaint was investigated and resolved."
)
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_h(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI."
))
else:
desc = data_broker_description or (
"Each such inquiry was refused, documented, and escalated."
)
_b(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_h(doc, "7. Breach Log Summary")
if not breaches:
_b(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications were "
f"required."
))
else:
_b(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported within 7 business days."
))
_h(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} and the undersigned acknowledge that CPNI rule "
f"violations may subject the carrier to forfeitures up to "
f"{MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act "
f"(adjusted per 47 CFR \u00a7 1.80)."
))
_b(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that no "
"material factual information has been withheld and all statements "
"are truthful, accurate, and complete."
))
_b(doc, (
"The undersigned acknowledges that willful false statements are "
"punishable under Title 18, U.S.C. \u00a7 1001, and by forfeiture "
"under 47 U.S.C. \u00a7 503."
))
_h(doc, "9. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
p = doc.add_paragraph(); _sp(p, after=0)
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10)
_sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph()
tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI IXC certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,270 @@
"""
Generate the FCC CPNI Annual Certification Letter IXC Reseller variant.
An IXC reseller buys wholesale toll minutes from an underlying carrier
and resells them under its own brand. CPNI obligations therefore extend
to both the retail end-user records the reseller maintains directly AND
to the toll-CDR flow from the wholesale carrier. Safeguards include
contractual flow-down terms with the upstream.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_ixc_reseller")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI IXC-Reseller generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "ixc_reseller"
VARIANT_LABEL = "Interexchange Carrier — Reseller"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def _cb(doc, text, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
r = p.add_run(f" {mark} {text}"); r.font.size = Pt(10)
_sp(p, after=3)
def generate_cpni_ixc_reseller(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
upstream_wholesale_provider: str = "",
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
upstream = upstream_wholesale_provider or "its wholesale underlying carrier(s)"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Interexchange Carrier — Reseller \u2014 47 CFR \u00a7 64.2009 "
f"\u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
_h(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein. I have reviewed the "
f"CPNI operating procedures of {entity_name}, including wholesale "
f"CPNI flow-down terms with upstream toll providers, and examined "
f"supervisory logs and records covering the reporting period."
))
_h(doc, "3. Certification of Compliance")
_b(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the CPNI rules at "
f"47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}."
))
_h(doc, "4. How Our Procedures Ensure Compliance")
_b(doc, (
f"As an IXC reseller purchasing wholesale toll minutes from "
f"{upstream} and reselling them under its own brand, {entity_name} "
f"protects CPNI at two boundaries: (1) the retail end-user toll-"
f"account interface, and (2) the CDR and billing-record flow from "
f"the underlying toll carrier(s)."
))
_cb(doc, (
"Interexchange account authentication is required before release "
"of any toll record to a customer-initiated inquiry "
"(47 CFR \u00a7 64.2010)."
))
_cb(doc, (
"PIC-change verifications (TPV, LOA, or Internet LOA) are performed "
"per 47 CFR \u00a7 64.1120 and confirmed to the customer's address "
"of record prior to implementation."
))
_cb(doc, (
f"Reseller flow-down: {entity_name}'s wholesale agreement with "
f"{upstream} expressly requires the upstream toll carrier to "
f"protect end-user CPNI received through {entity_name} consistent "
f"with 47 CFR \u00a7\u00a7 64.2001\u201364.2011. {entity_name} "
f"reviews upstream CPNI attestations annually."
))
_cb(doc, (
"The CPNI Protection Officer has oversight authority over both "
"retail toll-account records and wholesale CDR handoffs, and "
"reviews upstream carrier breach notices per 47 CFR \u00a7 64.2011."
))
_cb(doc, (
"Customer approval for CPNI usage beyond the scope of the "
"subscribed toll service is obtained through written or oral "
"opt-in consent, documented per 47 CFR \u00a7 64.2007."
))
_cb(doc, (
"Supervisory review of CPNI access occurs at least quarterly; "
"retention meets or exceeds two years (CPNI logs) and five years "
"(certification records) per 47 CFR \u00a7 64.2009."
))
_cb(doc, (
"Annual CPNI training is mandatory for all personnel with CPNI "
"access; breach notification procedures comply with 47 CFR "
"\u00a7 64.2011 as amended by FCC 23-111."
))
_h(doc, "5. Customer Complaints")
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or "Each complaint was investigated and resolved."
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_h(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI."
))
else:
desc = data_broker_description or "Each was refused, documented, and escalated."
_b(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_h(doc, "7. Breach Log Summary")
if not breaches:
_b(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications "
f"were required."
))
else:
_b(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported within 7 business days."
))
_h(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} and the undersigned officer acknowledge that CPNI "
f"rule violations may subject the carrier to forfeitures up to "
f"{MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act."
))
_b(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that "
"no material factual information has been withheld and that all "
"statements are truthful, accurate, and complete."
))
_b(doc, (
"The undersigned acknowledges that willful false statements are "
"punishable under Title 18, U.S.C. \u00a7 1001, and by forfeiture "
"under 47 U.S.C. \u00a7 503."
))
_h(doc, "9. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
p = doc.add_paragraph(); _sp(p, after=0)
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10); _sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph(); tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI IXC-Reseller certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,225 @@
"""
Generate the FCC CPNI Annual Certification Letter Private Line / BDS variant.
A private-line (point-to-point) or Business Data Service (BDS) offering
typically holds negligible CPNI: there is no switched calling, no PIC,
no per-call detail record, and no directory assistance. The carrier's
records are limited to circuit identifiers, service-address endpoints,
and enterprise billing data. These records generally fall outside the
statutory definition of CPNI at 47 USC § 222(h)(1), which is tied to
"telecommunications service" used by a customer.
This is a short, one-page certification that recites the carrier's
status, acknowledges the limited applicability of the CPNI rules to
its offerings, and commits to the same statutory safeguards (47 CFR
§ 1.17 truthfulness, Title 18 perjury acknowledgment, and forfeiture
awareness).
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_private_line")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI Private Line generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "private_line"
VARIANT_LABEL = "Private Line / Business Data Service (BDS)"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def generate_cpni_private_line(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
doc = Document()
for s in doc.sections:
s.top_margin = Inches(0.9); s.bottom_margin = Inches(0.9)
s.left_margin = Inches(1); s.right_margin = Inches(1)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Private Line / Business Data Service \u2014 "
f"47 CFR \u00a7 64.2009 \u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=6)
_h(doc, "1. Provider Information and Scope")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein and have reviewed "
f"{entity_name}'s records-handling procedures for private-line / "
f"Business Data Service (BDS) circuits covering the reporting "
f"period."
))
_h(doc, "3. Limited Applicability of CPNI Rules")
_b(doc, (
f"{entity_name}'s offerings consist principally of dedicated "
f"point-to-point private-line and/or Business Data Service "
f"circuits. These offerings generate no switched-call detail "
f"records, no presubscribed interexchange carrier (PIC) "
f"information, and no directory-assistance records. The records "
f"{entity_name} maintains \u2014 circuit identifiers, A-end and "
f"Z-end service addresses, and enterprise billing data \u2014 "
f"generally fall outside the statutory definition of Customer "
f"Proprietary Network Information at 47 USC \u00a7 222(h)(1), "
f"which is tied to the customer's use of a telecommunications "
f"service."
))
_b(doc, (
f"To the extent any subset of these records constitutes CPNI "
f"under the Commission's rules, {entity_name} certifies compliance "
f"with 47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}. "
f"Specifically, access to customer circuit records is restricted "
f"to authenticated personnel; authentication is required before "
f"disclosure in response to customer inquiries; annual training "
f"is provided; and breach-notification procedures comply with "
f"47 CFR \u00a7 64.2011 as amended by FCC 23-111."
))
_h(doc, "4. Customer Complaints and Data Broker Inquiries")
if complaints_count == 0 and not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any customer complaints "
f"concerning the unauthorized release or use of CPNI during "
f"the reporting period, and has NOT received any inquiries or "
f"communications from data brokers or other unauthorized "
f"parties seeking CPNI."
))
else:
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints "
f"during the reporting period."
))
else:
desc = complaints_description or "Each was investigated and resolved."
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''}. {desc}"
))
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any data broker or "
f"pretexting inquiries during the reporting period."
))
else:
desc = data_broker_description or "Each was refused and documented."
_b(doc, (
f"{entity_name} HAS received data broker / pretexting "
f"inquiries. {desc}"
))
_h(doc, "5. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} acknowledges that CPNI rule violations may subject "
f"the carrier to forfeitures up to {MAX_FORFEITURE_PER_VIOLATION} "
f"per violation and up to {MAX_FORFEITURE_CAP} for any single act "
f"or failure to act. Pursuant to 47 CFR \u00a7 1.17, the "
f"undersigned represents that no material factual information has "
f"been withheld and all statements are truthful, accurate, and "
f"complete. Willful false statements are punishable under Title "
f"18, U.S.C. \u00a7 1001, and by forfeiture under 47 U.S.C. "
f"\u00a7 503."
))
_h(doc, "6. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10); _sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph(); tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI Private Line certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,258 @@
"""
Generate the CPNI Procedure Statement internal / customer-facing policy.
This is **not** the annual ECFS certification letter (see
``cpni_cert_letter_generator.py`` for that). This is the 10-section policy
document every carrier must maintain and provide to customers under
47 CFR § 64.2008 (annual notice). Every example carrier in
``docs/examplefilings/`` has one of these alongside their CPNI cert
Cloud One PBX, Fortel, VoIPFlo, Engage, Syntracom, Zingo, TIP Systems
all using this exact 10-section outline.
Canonical section outline:
1. Purpose
2. Definition of CPNI
3. Employee Training and Compliance
4. Customer Authentication and Access Control
5. Use of CPNI
6. Customer Rights and Notification
7. CPNI Breach Notification and Reporting
8. Record Keeping and Audits
9. Enforcement and Penalties
10. Contact Information
Footer: Effective Date / Signatory / Reviewed By / Next Review Date.
Usage:
from scripts.document_gen.templates.cpni_procedure_statement_generator import (
generate_cpni_procedure_statement,
)
path = generate_cpni_procedure_statement(
entity_name="Falcon Broadband LLC",
entity_abbr="FBL",
support_email="support@falconbroadband.com",
website="https://falconbroadband.com",
signatory_name="Jane Doe",
signatory_title="President",
output_path="/tmp/cpni_policy.docx",
)
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_policy")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI policy generation unavailable")
Document = None # type: ignore[assignment, misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
BODY_SIZE = Pt(11) if Document else None
HEADING_SIZE = Pt(13) if Document else None
PARA_AFTER = Pt(6) if Document else None
def _heading(doc, text: str) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(4)
run = p.add_run(text)
run.bold = True
run.font.size = HEADING_SIZE
run.font.color.rgb = NAVY
def _body(doc, text: str, bold: bool = False) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_after = PARA_AFTER
run = p.add_run(text)
run.font.size = BODY_SIZE
run.bold = bold
def _bullets(doc, items: list[str]) -> None:
for item in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.25)
p.paragraph_format.space_after = Pt(3)
p.clear()
run = p.add_run(item)
run.font.size = BODY_SIZE
def generate_cpni_procedure_statement(
# Identity
entity_name: str,
entity_abbr: str = "",
# Customer-facing contacts
support_email: str = "",
website: str = "",
# Signatory (typically an officer)
signatory_name: str = "",
signatory_title: str = "",
# Dates
effective_date: str = "",
next_review_date: str = "",
# Reviewer (defaults to Performance West Inc.)
reviewer_name: str = "Justin Hannah",
reviewer_company: str = "Performance West Inc.",
# Small wording knobs
is_wholesale: bool = False,
# Output
output_path: str = "/tmp/cpni_procedure_statement.docx",
) -> Optional[str]:
"""Generate the 10-section CPNI Procedure Statement as a DOCX file."""
if Document is None:
LOG.error("python-docx not installed")
return None
abbr = entity_abbr or entity_name
today = date.today()
effective = effective_date or today.strftime("%m/%d/%Y")
next_review = next_review_date or today.replace(year=today.year + 1).strftime("%m/%d/%Y")
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
# Title
title_p = doc.add_paragraph()
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_run = title_p.add_run(
"Customer Proprietary Network Information (CPNI) Procedure Statement"
)
title_run.font.size = Pt(14)
title_run.bold = True
title_run.font.color.rgb = NAVY
title_p.paragraph_format.space_after = Pt(2)
subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub_run = subtitle.add_run(entity_name)
sub_run.font.size = Pt(12)
sub_run.bold = True
subtitle.paragraph_format.space_after = Pt(18)
scope = (
"wholesale customer proprietary data"
if is_wholesale
else "Customer Proprietary Network Information (CPNI)"
)
# ── 1. Purpose ──────────────────────────────────────────────────
_heading(doc, "1. Purpose")
_body(doc, (
f"{entity_name} is committed to protecting the confidentiality and "
f"security of {scope} as required by the Federal Communications "
f"Commission (FCC) under Section 222 of the Communications Act. "
f"This document outlines the procedures {entity_name} follows to "
f"ensure compliance with CPNI regulations set forth in 47 CFR "
f"\u00a7\u00a7 64.2001 through 64.2011."
))
# ── 2. Definition of CPNI ──────────────────────────────────────
_heading(doc, "2. Definition of CPNI")
_body(doc, (
"CPNI includes information related to the quantity, technical "
"configuration, type, destination, location, and amount of use of "
"telecommunications services by customers. It does not include "
"subscriber list information such as name, address, and telephone "
"number."
))
# ── 3. Employee Training and Compliance ────────────────────────
_heading(doc, "3. Employee Training and Compliance")
_bullets(doc, [
f"{entity_name} trains all employees in the handling, protection, and authorized use of CPNI.",
"Employees are prohibited from accessing or disclosing CPNI unless required for legitimate business purposes.",
"Any violation of CPNI policies may result in disciplinary action, including termination.",
])
# ── 4. Customer Authentication and Access Control ──────────────
_heading(doc, "4. Customer Authentication and Access Control")
_bullets(doc, [
f"{entity_name} authenticates customers before disclosing CPNI via telephone, online, or in-store interactions.",
"Telephone access to CPNI requires authentication through a pre-established password or by sending information to the customer's registered address.",
"Online account access requires a secure login process with multi-factor authentication where applicable.",
"In-person requests require valid government-issued identification.",
])
# ── 5. Use of CPNI ─────────────────────────────────────────────
_heading(doc, "5. Use of CPNI")
_bullets(doc, [
f"{entity_name} does not use CPNI for marketing purposes unless the customer provides explicit opt-in consent.",
"CPNI may be used for billing, fraud prevention, and service-related notifications.",
"CPNI is not shared with third parties unless required by law or with customer authorization.",
])
# ── 6. Customer Rights and Notification ────────────────────────
_heading(doc, "6. Customer Rights and Notification")
_bullets(doc, [
"Customers have the right to restrict the use of their CPNI for marketing purposes.",
f"{entity_name} provides annual CPNI notices informing customers of their rights and how to manage their CPNI preferences.",
"Customers may change their CPNI settings by contacting customer service.",
])
# ── 7. CPNI Breach Notification and Reporting ──────────────────
_heading(doc, "7. CPNI Breach Notification and Reporting")
_bullets(doc, [
f"In the case of a CPNI breach, {entity_name} follows FCC guidelines for reporting incidents per 47 CFR \u00a7 64.2011.",
"Notification is made to the FCC, FBI, and U.S. Secret Service (the Federal Agencies) via the central reporting facility as soon as practicable, and no later than 30 days after reasonable determination of a breach.",
"Customers are notified of unauthorized access as soon as practicable and in no event later than 30 days after notification to law enforcement (unless a delay is requested by law enforcement).",
"The company maintains records of CPNI breaches and reports them to law enforcement as required.",
])
# ── 8. Record Keeping and Audits ───────────────────────────────
_heading(doc, "8. Record Keeping and Audits")
_bullets(doc, [
f"{entity_name} maintains records of customer CPNI approvals, marketing usage, and access logs for at least two years.",
"The company conducts annual audits to ensure compliance with CPNI policies and regulatory requirements.",
])
# ── 9. Enforcement and Penalties ───────────────────────────────
_heading(doc, "9. Enforcement and Penalties")
_bullets(doc, [
"Any employee found violating CPNI policies will be subject to disciplinary actions, including possible termination.",
f"{entity_name} complies with all regulatory enforcement actions and may be subject to fines for non-compliance. Per FCC Enforcement Advisory DA-26-139, failure to comply with the CPNI rules may subject the company to monetary forfeitures of up to $251,322 per violation (up to a maximum of $2,513,215 for continuing violations).",
])
# ── 10. Contact Information ────────────────────────────────────
_heading(doc, "10. Contact Information")
_body(doc, (
f"For questions or concerns regarding CPNI policies, customers may "
f"contact {entity_name} Support:"
))
if support_email:
_body(doc, f"Email: {support_email}")
if website:
_body(doc, f"Website: {website}")
# ── Footer: dates + signatory ──────────────────────────────────
doc.add_paragraph("")
_body(doc, f"Effective Date: {effective}")
if signatory_name:
title_suffix = f", {signatory_title}" if signatory_title else ""
_body(doc, f"Signatory: {signatory_name}{title_suffix}, {entity_name}")
if reviewer_name:
_body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}")
_body(doc, f"Next Review Date: {next_review}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI Procedure Statement generated: %s", out)
return str(out)

View file

@ -0,0 +1,272 @@
"""
Generate the FCC CPNI Annual Certification Letter Satellite variant.
Tailors the CPNI certification under 47 CFR § 64.2009(e) for a provider
of Mobile Satellite Service (MSS) or Fixed Satellite Service (FSS)
operating or leasing earth-station capacity to deliver telecommunications
service. Variant specifics:
* Scope covers earth-station / NOC operator records, beam assignment
logs, and per-terminal activation records.
* Customer approval follows written / oral opt-in; many FSS
deployments authorize CPNI usage through the master service
agreement with the enterprise customer.
* Physical-security controls at earth stations (per Part 25 license
conditions) are incorporated into the CPNI safeguard narrative.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_satellite")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI Satellite generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "satellite"
VARIANT_LABEL = "Satellite (MSS / FSS)"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def _cb(doc, text, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
r = p.add_run(f" {mark} {text}"); r.font.size = Pt(10)
_sp(p, after=3)
def generate_cpni_satellite(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Satellite (MSS / FSS) \u2014 47 CFR \u00a7 64.2009 "
f"\u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
_h(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein, including procedures "
f"at the network operations center (NOC) governing subscriber "
f"terminal records, beam / transponder assignments, and enterprise "
f"master-service-agreement data. I have reviewed supervisory logs "
f"covering the reporting period."
))
_h(doc, "3. Certification of Compliance")
_b(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the CPNI rules at "
f"47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}."
))
_h(doc, "4. How Our Procedures Ensure Compliance")
_b(doc, (
f"As a provider of MSS and/or FSS telecommunications service, "
f"{entity_name}'s CPNI holdings consist primarily of: subscriber "
f"and enterprise-customer account records, per-terminal activation "
f"/ deactivation logs, beam and transponder assignment records, "
f"and NOC-generated usage reports. Specific procedures include:"
))
_cb(doc, (
"Customer authentication is required before CPNI release. "
"Consumer MSS subscribers authenticate via password; enterprise "
"FSS customers authenticate through credentials assigned to named "
"points of contact under the master service agreement "
"(47 CFR \u00a7 64.2010)."
))
_cb(doc, (
"Customer approval for CPNI usage beyond the scope of the "
"subscribed service is obtained through written opt-in consent, "
"documented in the customer record per 47 CFR \u00a7 64.2007. "
"Enterprise MSAs include the required opt-in as a standard clause."
))
_cb(doc, (
"Earth-station / NOC operator access to CPNI-bearing systems is "
"restricted to cleared personnel. Physical access is controlled "
"by badge and, where applicable, by the security requirements of "
"the Part 25 earth-station license."
))
_cb(doc, (
"The CPNI Protection Officer's oversight scope includes NOC "
"operator activity, terminal provisioning workflows, and "
"beam-assignment systems."
))
_cb(doc, (
"Supervisory review of CPNI access occurs at least quarterly. "
"Retention of access logs meets or exceeds two years (CPNI) and "
"five years (certification records) per 47 CFR \u00a7 64.2009."
))
_cb(doc, (
"Annual CPNI training is required for all personnel with CPNI "
"access; breach notification procedures comply with 47 CFR "
"\u00a7 64.2011 as amended by FCC 23-111."
))
_h(doc, "5. Customer Complaints")
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or "Each complaint was investigated and resolved."
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_h(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI."
))
else:
desc = data_broker_description or "Each was refused, documented, and escalated."
_b(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_h(doc, "7. Breach Log Summary")
if not breaches:
_b(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications "
f"were required."
))
else:
_b(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported within 7 business days."
))
_h(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} and the undersigned acknowledge that CPNI rule "
f"violations may subject the carrier to forfeitures up to "
f"{MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act."
))
_b(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that no "
"material factual information has been withheld and all statements "
"are truthful, accurate, and complete."
))
_b(doc, (
"Willful false statements are punishable under Title 18, U.S.C. "
"\u00a7 1001, and by forfeiture under 47 U.S.C. \u00a7 503."
))
_h(doc, "9. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
p = doc.add_paragraph(); _sp(p, after=0)
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10); _sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph(); tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI Satellite certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,282 @@
"""
Generate the FCC CPNI Annual Certification Letter Wireless (CMRS) variant.
Tailors the CPNI certification for a Commercial Mobile Radio Service
provider that operates its own radio / core network. Key variant
differences:
* Customer approval for CPNI use is frequently obtained through
handset-based (one-tap) mechanisms, in addition to traditional
opt-in.
* CPNI scope includes roaming records, eSIM provisioning / transfer
records, and device-level location data.
* Mobile location information is treated as CPNI and subject to
heightened consent safeguards consistent with the 2020 LocationSmart
Consent Decree (DA 20-299) and the 2024 Notice of Apparent Liability
against the Tier-1 carriers (FCC 24-40) addressing unauthorized
location-data sharing.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_wireless")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI Wireless generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "wireless"
VARIANT_LABEL = "Wireless (CMRS) — Facilities"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def _cb(doc, text, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
r = p.add_run(f" {mark} {text}"); r.font.size = Pt(10)
_sp(p, after=3)
def generate_cpni_wireless(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Wireless (CMRS) Facilities \u2014 47 CFR \u00a7 64.2009 "
f"\u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
_h(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein, including procedures "
f"governing device-level location data, roaming records, SIM / eSIM "
f"provisioning, and handset-based customer consent flows. I have "
f"reviewed operating procedures and supervisory logs covering the "
f"reporting period."
))
_h(doc, "3. Certification of Compliance")
_b(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the CPNI rules at "
f"47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}."
))
_h(doc, "4. How Our Procedures Ensure Compliance")
_b(doc, (
f"As a Commercial Mobile Radio Service (CMRS) provider, "
f"{entity_name}'s CPNI holdings include call detail records, "
f"roaming records, SIM/eSIM provisioning and transfer logs, and "
f"device-level location data. Specific procedures include:"
))
_cb(doc, (
"Customer authentication for CPNI disclosures in response to a "
"customer-initiated contact uses a pre-established password, or "
"in-app / on-device verification (biometric or PIN) tied to the "
"authenticated subscriber identity (47 CFR \u00a7 64.2010)."
))
_cb(doc, (
"Customer approval for CPNI use beyond the scope of the subscribed "
"service may be obtained through traditional opt-in (written / oral) "
"OR through a secure one-tap in-app consent flow that meets the "
"FCC's 'knowing consent' standard under 47 CFR \u00a7 64.2007. "
"Consents are timestamped and retained."
))
_cb(doc, (
"Location data consent. Consistent with the 2020 LocationSmart "
"Consent Decree (DA 20-299) and the 2024 NAL addressing unauthorized "
"third-party location disclosure, {entity_name} treats device "
"location data as CPNI and requires separate, express consent for "
"disclosure to any third party. A chain-of-consent audit is "
"performed for each location-data aggregator relationship."
).replace("{entity_name}", entity_name))
_cb(doc, (
"SIM / eSIM transfer (port-out / device-swap) requires multi-factor "
"authentication plus customer notification to the address of record "
"prior to completion \u2014 implementing the anti-SIM-swap rules "
"codified at 47 CFR \u00a7 64.2010(f)\u2013(g)."
))
_cb(doc, (
"Roaming records are protected with the same safeguards as home-"
"network CDRs; access is logged to the named individual and "
"reviewed quarterly by the CPNI Protection Officer."
))
_cb(doc, (
"Access logs are retained for at least two years; certification "
"records for five years; access is reviewed at least quarterly."
))
_cb(doc, (
"Annual CPNI training is mandatory for all personnel with CPNI "
"access; breach notification procedures comply with 47 CFR "
"\u00a7 64.2011 as amended by FCC 23-111."
))
_h(doc, "5. Customer Complaints")
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI (including unauthorized location-data "
f"disclosures). Zero (0) complaints were logged."
))
else:
desc = complaints_description or "Each complaint was investigated and resolved."
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_h(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI or device location "
f"data."
))
else:
desc = data_broker_description or "Each was refused, documented, and escalated."
_b(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_h(doc, "7. Breach Log Summary")
if not breaches:
_b(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications were "
f"required."
))
else:
_b(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported within 7 business days."
))
_h(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} and the undersigned acknowledge that CPNI rule "
f"violations may subject the carrier to forfeitures up to "
f"{MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act."
))
_b(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that no "
"material factual information has been withheld and all statements "
"are truthful, accurate, and complete."
))
_b(doc, (
"Willful false statements are punishable under Title 18, U.S.C. "
"\u00a7 1001, and by forfeiture under 47 U.S.C. \u00a7 503."
))
_h(doc, "9. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
p = doc.add_paragraph(); _sp(p, after=0)
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10); _sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph(); tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI Wireless certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,273 @@
"""
Generate the FCC CPNI Annual Certification Letter Wireless MVNO variant.
An MVNO does not own spectrum or a radio-access network; it resells a
host MNO's wireless service under its own brand. The MVNO directly
controls retail billing records, device-ordering records, and customer
support authentication flows. Everything touching the radio network
(location signaling, HLR / HSS attach records) is held by the host MNO
under its own CPNI certification. This variant clarifies the dividing
line and reinforces delegation + flow-down language.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.cpni_wireless_mvno")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CPNI Wireless MVNO generation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
VARIANT_ID = "wireless_mvno"
VARIANT_LABEL = "Wireless (CMRS) — MVNO"
MAX_FORFEITURE_PER_VIOLATION = "$251,322"
MAX_FORFEITURE_CAP = "$2,513,215"
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def _cb(doc, text, checked=True):
mark = "\u2611" if checked else "\u2610"
p = doc.add_paragraph()
r = p.add_run(f" {mark} {text}"); r.font.size = Pt(10)
_sp(p, after=3)
def generate_cpni_wireless_mvno(
output_path: str,
entity_name: str,
frn: str = "",
filer_id_499: str = "",
officer_name: str = "",
officer_title: str = "Chief Executive Officer",
complaints_count: int = 0,
complaints_description: str = "",
has_data_broker_inquiries: bool = False,
data_broker_description: str = "",
reporting_year: int = 0,
host_mno_name: str = "",
address_street: str = "",
address_city: str = "",
address_state: str = "",
address_zip: str = "",
contact_email: str = "",
contact_phone: str = "",
breaches: list[dict] | None = None,
**_: dict,
) -> Optional[str]:
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year - 1
breaches = breaches or []
host = host_mno_name or "its host Mobile Network Operator"
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)
today = datetime.now().strftime("%B %d, %Y")
signer = officer_name or "Authorized Officer"
title = officer_title or "Officer"
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("CPNI Annual Certification Letter")
t.font.size = Pt(14); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"Wireless (CMRS) — MVNO \u2014 47 CFR \u00a7 64.2009 "
f"\u2014 Calendar Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
_h(doc, "1. Provider Information")
lines = [f"Company Name: {entity_name}", f"Variant: {VARIANT_LABEL}"]
if frn: lines.append(f"FCC Registration Number (FRN): {frn}")
if filer_id_499: lines.append(f"FCC Form 499 Filer ID: {filer_id_499}")
addr = ", ".join(filter(None, [address_street, address_city]))
if address_state or address_zip:
addr += f", {address_state} {address_zip}".strip()
if addr.strip(", "):
lines.append(f"Address: {addr.strip(', ')}")
if contact_phone: lines.append(f"Telephone: {contact_phone}")
if contact_email: lines.append(f"Email: {contact_email}")
lines.append(f"Certifying Officer: {signer}, {title}")
lines.append(f"Host MNO: {host}")
lines.append(f"Date of Filing: {today}")
lines.append(f"Filing Deadline: March 2, {reporting_year + 1}")
_b(doc, "\n".join(lines))
_h(doc, "2. Officer Statement of Personal Knowledge")
_b(doc, (
f"I, {signer}, {title} of {entity_name}, state that I have personal "
f"knowledge of the matters certified herein. I have reviewed "
f"{entity_name}'s CPNI procedures, the CPNI-related portions of "
f"the MVNO wholesale agreement with {host}, and supervisory logs "
f"covering the reporting period."
))
_h(doc, "3. Certification of Compliance")
_b(doc, (
f"Pursuant to 47 CFR \u00a7 64.2009(e), {entity_name} hereby submits "
f"its annual certification of compliance with the CPNI rules at "
f"47 CFR \u00a7\u00a7 64.2001 through 64.2011 for the period "
f"January 1, {reporting_year} through December 31, {reporting_year}."
))
_h(doc, "4. How Our Procedures Ensure Compliance")
_b(doc, (
f"As a Mobile Virtual Network Operator (MVNO) that does not own "
f"spectrum or a radio-access network, {entity_name}'s CPNI "
f"responsibilities divide into (A) CPNI that {entity_name} directly "
f"controls and (B) CPNI that is held, transported, or generated by "
f"{host} and made available to {entity_name} pursuant to the MVNO "
f"agreement. Both are protected."
))
_b(doc, f"(A) CPNI directly controlled by {entity_name}:", bold=True)
_cb(doc, (
"Retail customer records, billing data, plan-change history, and "
"customer-support interaction logs are held in systems owned and "
"administered by {entity_name} and are subject to password-based "
"authentication, opt-in consent for marketing use, quarterly "
"supervisory review, and annual training per 47 CFR \u00a7\u00a7 "
"64.2005\u201364.2010."
).replace("{entity_name}", entity_name))
_cb(doc, (
"SIM / eSIM port-out and device-swap orders received by "
"{entity_name} require multi-factor authentication of the "
"subscriber plus notification to the address of record before the "
"order is submitted to {host} for execution (47 CFR \u00a7 64.2010)."
).replace("{entity_name}", entity_name).replace("{host}", host))
_b(doc, f"(B) CPNI delegated to or held by {host}:", bold=True)
_cb(doc, (
"Radio-access network signaling, HLR / HSS / UDM records, "
"per-device location information, and lawful-intercept records "
"are held by the host MNO and are governed by that host MNO's own "
"CPNI certification and safeguards. {entity_name} does NOT have "
"direct access to these data stores."
).replace("{entity_name}", entity_name))
_cb(doc, (
f"The MVNO wholesale agreement between {entity_name} and {host} "
f"expressly requires {host} to protect all CPNI generated through "
f"{entity_name}'s subscribers consistent with 47 CFR "
f"\u00a7\u00a7 64.2001\u201364.2011, to provide breach notice to "
f"{entity_name} within a commercially reasonable time, and to "
f"limit use of such CPNI to the provision of service to "
f"{entity_name} and its subscribers."
))
_cb(doc, (
f"{entity_name} reviews {host}'s annual CPNI certification and "
f"any published breach notices, and maintains a file of the "
f"current executed MVNO agreement."
))
_h(doc, "5. Customer Complaints")
if complaints_count == 0:
_b(doc, (
f"{entity_name} has NOT received any customer complaints during "
f"the reporting period concerning the unauthorized release or "
f"use of CPNI. Zero (0) complaints were logged."
))
else:
desc = complaints_description or "Each complaint was investigated and resolved."
_b(doc, (
f"{entity_name} HAS received {complaints_count} customer "
f"complaint{'s' if complaints_count != 1 else ''} during the "
f"reporting period. {desc}"
))
_h(doc, "6. Data Broker Inquiries and Pretexting")
if not has_data_broker_inquiries:
_b(doc, (
f"{entity_name} has NOT received any inquiries, communications, "
f"or attempts by data brokers or other unauthorized parties "
f"seeking the unauthorized release of CPNI."
))
else:
desc = data_broker_description or "Each was refused, documented, and escalated."
_b(doc, (
f"{entity_name} HAS received data broker or pretexting-style "
f"inquiries during the reporting period. {desc}"
))
_h(doc, "7. Breach Log Summary")
if not breaches:
_b(doc, (
f"{entity_name} experienced no CPNI breaches during the "
f"reporting period. No 47 CFR \u00a7 64.2011 notifications "
f"were required."
))
else:
_b(doc, (
f"{entity_name} experienced {len(breaches)} CPNI breach"
f"{'es' if len(breaches) != 1 else ''} during the reporting "
f"period; each was reported within 7 business days."
))
_h(doc, "8. Penalties, Truthfulness, and Perjury Acknowledgment")
_b(doc, (
f"{entity_name} and the undersigned acknowledge that CPNI rule "
f"violations may subject the carrier to forfeitures up to "
f"{MAX_FORFEITURE_PER_VIOLATION} per violation and up to "
f"{MAX_FORFEITURE_CAP} for any single act or failure to act."
))
_b(doc, (
"Pursuant to 47 CFR \u00a7 1.17, the undersigned represents that no "
"material factual information has been withheld and all statements "
"are truthful, accurate, and complete."
))
_b(doc, (
"Willful false statements are punishable under Title 18, U.S.C. "
"\u00a7 1001, and by forfeiture under 47 U.S.C. \u00a7 503."
))
_h(doc, "9. Signature of Certifying Officer")
_b(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that the foregoing is true and correct."
))
p = doc.add_paragraph(); _sp(p, after=0)
sig = doc.add_paragraph(); sig.add_run("_" * 45).font.size = Pt(10); _sp(sig, after=2)
nm = doc.add_paragraph(); nr = nm.add_run(signer); nr.bold = True
nr.font.size = Pt(10); _sp(nm, after=2)
tpp = doc.add_paragraph(); tpp.add_run(f"{title}, {entity_name}").font.size = Pt(10)
_sp(tpp, after=2)
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("CPNI Wireless MVNO certification letter generated: %s", out)
return str(out)

View file

@ -0,0 +1,252 @@
"""
Generate the CRTC Registration Notification Letter as a DOCX file.
This produces a formal letter addressed to the Secretary General of the CRTC
notifying them that a new telecommunications service provider has been
established under a BC corporation and wishes to register as a:
- Voice, Data & Wireless Reseller (domestic)
- Basic International Telecommunications Service (BITS) provider (if applicable)
The letter follows the format specified at:
https://crtc.gc.ca/eng/comm/telecom/registr4.htm
Usage:
from scripts.document_gen.templates.crtc_letter_generator import generate_crtc_letter
pdf_path = generate_crtc_letter(
entity_name="1234567 B.C. Ltd.",
incorporation_number="1234567",
registered_office="329 Howe St, Vancouver, BC V6C 3N2",
services_description="Resale of voice, data, and wireless services...",
geographic_coverage="BC and Worldwide",
include_bits=True,
regulatory_contact_name="Regulatory Director",
regulatory_contact_email="regulatory@example.ca",
regulatory_contact_phone="+16045551234",
director_name="John Doe",
output_path="/tmp/crtc_letter.docx",
)
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.crtc_letter")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — CRTC letter generation unavailable")
Document = None
def generate_crtc_letter(
entity_name: str,
incorporation_number: str,
registered_office: str,
services_description: str,
geographic_coverage: str = "Canada-wide",
include_bits: bool = True,
regulatory_contact_name: str = "Regulatory Director",
regulatory_contact_email: str = "",
regulatory_contact_phone: str = "",
director_name: str = "",
ca_domain: str = "",
output_path: str = "/tmp/crtc_notification_letter.docx",
) -> Optional[str]:
"""
Generate a CRTC Registration Notification Letter as a DOCX file.
Returns the output file path on success, None on failure.
"""
if Document is None:
LOG.error("python-docx not installed")
return None
doc = Document()
# Page margins
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
# ── Sender block ──────────────────────────────────────────
today = datetime.now().strftime("%B %d, %Y")
sender = doc.add_paragraph()
sender.alignment = WD_ALIGN_PARAGRAPH.LEFT
sender_run = sender.add_run(
f"{entity_name}\n"
f"Incorporation No. {incorporation_number}\n"
f"{registered_office}\n"
)
sender_run.font.size = Pt(10)
if regulatory_contact_phone:
sender.add_run(f"Tel: {regulatory_contact_phone}\n").font.size = Pt(10)
if regulatory_contact_email:
sender.add_run(f"Email: {regulatory_contact_email}\n").font.size = Pt(10)
if ca_domain:
sender.add_run(f"Web: https://{ca_domain}\n").font.size = Pt(10)
# Date
date_para = doc.add_paragraph()
date_para.alignment = WD_ALIGN_PARAGRAPH.LEFT
date_run = date_para.add_run(today)
date_run.font.size = Pt(10)
# ── Addressee ─────────────────────────────────────────────
doc.add_paragraph()
addressee = doc.add_paragraph()
addressee_run = addressee.add_run(
"Secretary General\n"
"Canadian Radio-television and\n"
"Telecommunications Commission (CRTC)\n"
"Ottawa, Ontario\n"
"K1A 0N2"
)
addressee_run.font.size = Pt(10)
# ── Subject line ──────────────────────────────────────────
doc.add_paragraph()
subject = doc.add_paragraph()
subject_run = subject.add_run(
f"Re: Registration as a Telecommunications Service Provider — {entity_name}"
)
subject_run.font.size = Pt(10)
subject_run.bold = True
# ── Body ──────────────────────────────────────────────────
doc.add_paragraph()
# Introduction
intro = doc.add_paragraph()
intro_run = intro.add_run(
f"Dear Secretary General,\n\n"
f"Pursuant to the Telecommunications Act, S.C. 1993, c. 38, and the "
f"Canadian Radio-television and Telecommunications Commission's registration "
f"requirements for telecommunications service providers, {entity_name} "
f"(Incorporation No. {incorporation_number}) hereby notifies the Commission of its "
f"intention to provide telecommunications services in Canada."
)
intro_run.font.size = Pt(10)
# Company information section
doc.add_paragraph()
info_heading = doc.add_paragraph()
info_heading_run = info_heading.add_run("1. Company Information")
info_heading_run.font.size = Pt(10)
info_heading_run.bold = True
info = doc.add_paragraph()
info.style.font.size = Pt(10)
info_text = (
f"Legal Name: {entity_name}\n"
f"Incorporation Number: {incorporation_number}\n"
f"Mailing Address: {registered_office}\n"
f"Telephone: {regulatory_contact_phone}\n"
f"Email: {regulatory_contact_email}\n"
)
info.add_run(info_text).font.size = Pt(10)
# Services section
doc.add_paragraph()
svc_heading = doc.add_paragraph()
svc_heading_run = svc_heading.add_run("2. Description of Services")
svc_heading_run.font.size = Pt(10)
svc_heading_run.bold = True
svc = doc.add_paragraph()
svc.add_run(
f"{entity_name} intends to operate as a reseller of voice, data, and wireless "
f"telecommunications services. Specifically:\n\n"
f"{services_description}\n\n"
f"Geographic Coverage: {geographic_coverage}"
).font.size = Pt(10)
# Registration type
doc.add_paragraph()
reg_heading = doc.add_paragraph()
reg_heading_run = reg_heading.add_run("3. Registration Category")
reg_heading_run.font.size = Pt(10)
reg_heading_run.bold = True
reg = doc.add_paragraph()
reg_text = f"{entity_name} registers as a Voice, Data & Wireless Reseller."
if include_bits:
reg_text += (
f"\n\n{entity_name} also intends to provide Basic International "
f"Telecommunications Services (BITS) and will file a separate notification "
f"with the Commission pursuant to CRTC Telecom Decision 98-17."
)
reg.add_run(reg_text).font.size = Pt(10)
# Response Manager
doc.add_paragraph()
rm_heading = doc.add_paragraph()
rm_heading_run = rm_heading.add_run("4. Response Manager for Regulatory Matters")
rm_heading_run.font.size = Pt(10)
rm_heading_run.bold = True
rm = doc.add_paragraph()
rm.add_run(
f"Name: {regulatory_contact_name}\n"
f"Title: Regulatory Director\n"
f"Organization: {entity_name}\n"
f"Address: {registered_office}\n"
f"Telephone: {regulatory_contact_phone}\n"
f"Email: {regulatory_contact_email}"
).font.size = Pt(10)
# Compliance commitment
doc.add_paragraph()
compliance_heading = doc.add_paragraph()
compliance_heading_run = compliance_heading.add_run("5. Compliance")
compliance_heading_run.font.size = Pt(10)
compliance_heading_run.bold = True
compliance = doc.add_paragraph()
compliance.add_run(
f"{entity_name} confirms that it will comply with all applicable provisions of "
f"the Telecommunications Act, CRTC regulations, and conditions of service, "
f"including participation in the Commission for Complaints for "
f"Telecom-Television Services (CCTS)."
).font.size = Pt(10)
# Closing
doc.add_paragraph()
doc.add_paragraph()
closing = doc.add_paragraph()
closing.add_run("Respectfully submitted,").font.size = Pt(10)
# Signature block (space for eSign)
doc.add_paragraph()
doc.add_paragraph() # Space for signature
doc.add_paragraph()
sig_line = doc.add_paragraph()
sig_line.add_run("_" * 40).font.size = Pt(10)
sig_name = doc.add_paragraph()
sig_name_run = sig_name.add_run(director_name or regulatory_contact_name)
sig_name_run.font.size = Pt(10)
sig_name_run.bold = True
sig_title = doc.add_paragraph()
sig_title.add_run(f"Director, {entity_name}").font.size = Pt(10)
sig_date = doc.add_paragraph()
sig_date.add_run(f"Date: {today}").font.size = Pt(10)
# Save
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output))
LOG.info("CRTC letter generated: %s", output)
return str(output)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,427 @@
"""
Generate the 499-A Revenue Calculation Workbook (xlsx).
Companion workbook customers fill in with their monthly/quarterly revenue
splits. The workbook roll-ups feed the actual 499-A Block 3, 4-A, 4-B,
and 5 line items that the Playwright handler pushes into USAC E-File.
Design principles:
* Entirely local formulas live in the workbook, not here. Customers open
it in Excel/Google Sheets, plug in numbers, and the totals flip.
* Jurisdictional splits per 2026 Instructions (section IV.C.5): intrastate
/ interstate / international. Column D/E/F carry the split percentages
so customers can override the VoIP safe harbor if they have a traffic
study.
* Every revenue row maps to a specific Form 499-A line (e.g. Line 404.1,
Line 414, Line 418.4) so the admin can lift values straight into E-File.
Sheets produced:
1. README explains the workbook, filing window, where values feed.
2. Block 3 Carrier's Carrier Revenue (Lines 301-315)
3. Block 4-A End-User & Non-Telecom Revenue (Lines 401-418)
4. Block 4-B Totals & Uncollectibles (Lines 419-423)
5. Block 5 Regional Percentages + TRS + Reseller Exclusions
6. Summary single-page dashboard of all key roll-ups
Usage:
from scripts.document_gen.templates.form_499a_revenue_workbook_generator import (
generate_499a_revenue_workbook,
)
path = generate_499a_revenue_workbook(
entity_name="Falcon Broadband LLC",
filer_id_499="812345",
reporting_year=2025,
voip_safe_harbor_pct=64.9,
output_path="/tmp/499a_workbook.xlsx",
)
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.499a_workbook")
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
except ImportError:
LOG.warning("openpyxl not installed — 499-A workbook generation unavailable")
Workbook = None # type: ignore[assignment,misc]
# Style constants
NAVY_FILL = "FF1A2744"
LIGHT_FILL = "FFF8FAFC"
BORDER_THIN = None # set lazily (needs openpyxl)
HEADER_FONT = None
TITLE_FONT = None
def _init_styles():
"""Lazy style init — openpyxl objects can't be module-level if import fails."""
global BORDER_THIN, HEADER_FONT, TITLE_FONT
if BORDER_THIN is None:
side = Side(style="thin", color="FFCBD5E1")
BORDER_THIN = Border(left=side, right=side, top=side, bottom=side)
HEADER_FONT = Font(name="Calibri", size=11, bold=True, color="FFFFFFFF")
TITLE_FONT = Font(name="Calibri", size=14, bold=True, color="FF1A2744")
# Block 3: Carrier's Carrier Revenue (Lines 301-315).
# Each tuple is (line_num, label, is_total_row).
_BLOCK_3_LINES = [
("303.1", "Fixed local — UNEs", False),
("303.2", "Fixed local — other arrangements", False),
("304.1", "Per-minute charges — state/federal access tariff", False),
("304.2", "Per-minute — UNEs or other arrangement", False),
("305.1", "Local private line — for resale as telecom", False),
("305.2", "Local private line — for resale as interconnected VoIP", False),
("306", "Payphone compensation from toll carriers", False),
("307", "Other local telecom service revenues", False),
("308", "Universal service support revenues from Federal/state", False),
("309", "Mobile — monthly/activation/message (non-toll)", False),
("310", "Operator/toll with alternative billing", False),
("311", "Ordinary long distance (direct-dialed MTS, toll-free, etc.)", False),
("312", "Long distance private line services", False),
("313", "Satellite services", False),
("314", "All other long distance services", False),
("315", "TOTAL revenues from resale (sum 303314)", True),
]
# Block 4-A: End-User and Non-Telecom Revenue (Lines 401-418).
_BLOCK_4A_LINES = [
("403", "Surcharges recovering USF contributions", False),
("404.1", "Fixed flat-rate w/ interstate toll — local portion", False),
("404.2", "Fixed flat-rate w/ interstate toll — toll portion", False),
("404.3", "Fixed — no interstate toll included", False),
("404.4", "Interconnected VoIP — with broadband connection", False),
("404.5", "Interconnected VoIP — independent of broadband", False),
("405", "Tariffed SLC / ARC / PICC (LEC no-PIC)", False),
("406", "Local private line & special access (wireline broadband)", False),
("407", "Payphone coin revenues (local + LD)", False),
("408", "Other local telecom service revenues", False),
("409", "Mobile — monthly and activation charges", False),
("410", "Mobile — message / roaming / air-time (excl. separately stated toll)", False),
("411", "Prepaid calling card (at face value)", False),
("412", "International calls — both endpoints foreign", False),
("413", "Operator/toll with alternative billing (non-412)", False),
("414.1", "Ordinary LD — non-VoIP", False),
("414.2", "Ordinary LD — interconnected VoIP", False),
("415", "Long distance private line services", False),
("416", "Satellite services", False),
("417", "All other long distance services", False),
("418.1", "Bundled — with circuit-switched local", False),
("418.2", "Bundled — with interconnected VoIP local", False),
("418.3", "Other bundled / non-telecom", False),
("418.4", "Non-interconnected VoIP (not in other categories)", False),
]
# Block 4-B: Total / uncollectible (Lines 419-423).
_BLOCK_4B_LINES = [
("419", "Gross billed revenues from all sources", False),
("420", "Gross universal service contribution base", False),
("421", "Uncollectible associated with Line 419", False),
("422", "Uncollectible associated with Line 420", False),
("423", "Net universal service contribution base (420 422)", True),
]
# Block 5 regional breakout rows (Lines 503-510).
_BLOCK_5_REGIONS = [
("503", "Southeast: AL, FL, GA, KY, LA, MS, NC, PR, SC, TN, USVI"),
("504", "Western: AK, AZ, CO, ID, IA, MN, MT, NE, NM, ND, OR, SD, UT, WA, WY"),
("505", "West Coast: CA, HI, NV, AS, GU, Johnston, Midway, MP, Wake"),
("506", "Mid-Atlantic: DE, DC, MD, NJ, PA, VA, WV"),
("507", "Mid-West: IL, IN, MI, OH, WI"),
("508", "Northeast: CT, ME, MA, NH, NY, RI, VT"),
("509", "Southwest: AR, KS, MO, OK, TX"),
("510", "TOTAL (must sum to 100%)"),
]
def _set_header_row(ws, row: int, headers: list[str]) -> None:
_init_styles()
navy = PatternFill(start_color=NAVY_FILL, end_color=NAVY_FILL, fill_type="solid")
for col, header in enumerate(headers, start=1):
cell = ws.cell(row=row, column=col, value=header)
cell.font = HEADER_FONT
cell.fill = navy
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = BORDER_THIN
def _write_revenue_block(
ws,
*,
title: str,
lines: list[tuple[str, str, bool]],
start_row: int = 1,
) -> int:
"""Write a Block 3 / 4-A / 4-B style sheet. Returns next free row."""
_init_styles()
ws.cell(row=start_row, column=1, value=title).font = TITLE_FONT
row = start_row + 2
_set_header_row(ws, row, [
"Line",
"Description",
"(a) Total Revenue",
"(b) Interstate %",
"(c) International %",
"(d) Interstate $",
"(e) International $",
])
row += 1
total_row_idx = None
for line_num, label, is_total in lines:
if is_total:
total_row_idx = row
ws.cell(row=row, column=1, value=line_num).font = Font(bold=True)
ws.cell(row=row, column=2, value=label).font = Font(bold=True)
# TOTAL formula
data_range = f"C{row - len(lines) + 1}:C{row - 1}"
ws.cell(row=row, column=3, value=f"=SUM({data_range})").font = Font(bold=True)
ws.cell(row=row, column=6, value=f"=SUM(F{row - len(lines) + 1}:F{row - 1})").font = Font(bold=True)
ws.cell(row=row, column=7, value=f"=SUM(G{row - len(lines) + 1}:G{row - 1})").font = Font(bold=True)
else:
ws.cell(row=row, column=1, value=line_num)
ws.cell(row=row, column=2, value=label)
# Column C is the total revenue input (customer fills).
# Columns D/E percentages feed formulas in F/G.
ws.cell(row=row, column=6, value=f"=C{row}*D{row}/100")
ws.cell(row=row, column=7, value=f"=C{row}*E{row}/100")
for col in range(1, 8):
ws.cell(row=row, column=col).border = BORDER_THIN
# Currency formatting
for col in (3, 6, 7):
ws.cell(row=row, column=col).number_format = '"$"#,##0.00'
# Percentage columns
for col in (4, 5):
ws.cell(row=row, column=col).number_format = '0.00"%"'
row += 1
# Column widths
ws.column_dimensions["A"].width = 10
ws.column_dimensions["B"].width = 55
for col_letter in ("C", "D", "E", "F", "G"):
ws.column_dimensions[col_letter].width = 16
return row + 1
def generate_499a_revenue_workbook(
# Entity
entity_name: str,
filer_id_499: str = "",
frn: str = "",
# Reporting period
reporting_year: int = 0,
# VoIP safe harbor (populated in README)
voip_safe_harbor_pct: float = 64.9,
# Pre-fill: optional traffic study row from cdr_traffic_studies.
# When provided, interstate / international percentages on each
# revenue line pre-populate from the study, saving the customer
# from typing them in — reviewer still validates before filing.
traffic_study: Optional[dict] = None,
# Output
output_path: str = "/tmp/form_499a_revenue_workbook.xlsx",
) -> Optional[str]:
"""Produce the 499-A revenue calculation workbook.
When ``traffic_study`` is passed (a row from ``cdr_traffic_studies``),
the workbook pre-fills interstate/international % cells across the
revenue blocks using the study's computed values. Block 5 regional
rows pre-fill with BOTH the orig-state and billing-state percentages
so the admin chooses at submission time.
"""
if Workbook is None:
LOG.error("openpyxl not installed")
return None
_init_styles()
if reporting_year == 0:
reporting_year = datetime.now().year - 1
interstate_pct_prefill = (traffic_study or {}).get("interstate_pct")
international_pct_prefill = (traffic_study or {}).get("international_pct")
orig_regions = (traffic_study or {}).get("orig_state_regions_json") or {}
billing_regions = (traffic_study or {}).get("billing_state_regions_json") or {}
wb = Workbook()
# Kill the default sheet
default = wb.active
wb.remove(default)
# ── README sheet ────────────────────────────────────────────────
ws = wb.create_sheet("README")
ws.cell(row=1, column=1, value=f"2026 FCC Form 499-A Revenue Calculation Workbook").font = TITLE_FONT
ws.cell(row=2, column=1, value=f"Reporting calendar year {reporting_year}").font = Font(italic=True)
ws.cell(row=3, column=1, value=f"Filer: {entity_name} | Filer 499 ID: {filer_id_499 or '(pending)'} | FRN: {frn or '(pending)'}")
readme = [
"",
"How to use this workbook:",
" 1. Open each Block sheet (Block 3, Block 4-A, Block 4-B, Block 5).",
" 2. In column C (\u201cTotal Revenue\u201d), enter each Line item's gross billed revenue for the year.",
" 3. In columns D and E, enter the interstate % and international % for each line.",
" If you are an interconnected VoIP provider and have not conducted a traffic study, you may",
f" use the 2026 VoIP safe harbor of {voip_safe_harbor_pct}% for lines 404.4 / 404.5 / 414.2.",
" 4. Columns F and G calculate automatically. Block 4-B pulls totals from Block 4-A.",
" 5. The Summary sheet gives a one-page view of everything that feeds the filing.",
"",
"Important:",
" \u2022 Report dollar amounts in whole dollars (round >$1000 to nearest thousand per Section G).",
" \u2022 Do NOT enter negative numbers on any billed revenue line (see Lines 421/422 for uncollectibles).",
" \u2022 The 2026 Form 499-A (reporting 2025 revenues) is due April 1, 2026.",
" \u2022 E-File opens March 2, 2026. File online at https://forms.universalservice.org.",
"",
"Block assignments (2025/2026 form — structure unchanged):",
" \u2022 Block 3 = Carrier's Carrier Revenue (Lines 301\u2013315)",
" \u2022 Block 4-A = End-User and Non-Telecom Revenue (Lines 401\u2013418)",
" \u2022 Block 4-B = Total Revenue + Uncollectibles (Lines 419\u2013423)",
" \u2022 Block 5 = Regional Percentage Breakouts + TRS contribution base + Reseller Exclusions",
"",
"Questions: contact support@performancewest.net or 888-411-0383.",
]
for i, line in enumerate(readme, start=4):
ws.cell(row=i, column=1, value=line)
ws.column_dimensions["A"].width = 110
# ── Block 3 ─────────────────────────────────────────────────────
ws3 = wb.create_sheet("Block 3")
_write_revenue_block(
ws3,
title=f"Block 3 — Carrier's Carrier Revenue (Reporting {reporting_year})",
lines=_BLOCK_3_LINES,
)
# ── Block 4-A ───────────────────────────────────────────────────
ws4a = wb.create_sheet("Block 4-A")
_write_revenue_block(
ws4a,
title=f"Block 4-A — End-User and Non-Telecom Revenue (Reporting {reporting_year})",
lines=_BLOCK_4A_LINES,
)
# ── Block 4-B ───────────────────────────────────────────────────
ws4b = wb.create_sheet("Block 4-B")
ws4b.cell(row=1, column=1, value="Block 4-B — Total Revenue and Uncollectible Revenue").font = TITLE_FONT
_set_header_row(ws4b, 3, ["Line", "Description", "Total Revenue", "Interstate", "International"])
# Line 419 pulls from Block 3 Line 315 + Block 4-A total
ws4b.cell(row=4, column=1, value="419")
ws4b.cell(row=4, column=2, value="Gross billed revenues from all sources")
ws4b.cell(row=4, column=3, value="='Block 3'!C" + str(3 + len(_BLOCK_3_LINES) + 1) + "+'Block 4-A'!C" + str(3 + len(_BLOCK_4A_LINES) + 1))
# Line 420 — contribution base (lines 403-411 + 413-417 end-user)
ws4b.cell(row=5, column=1, value="420")
ws4b.cell(row=5, column=2, value="Gross USF contribution base amounts (fill manually; see Table 3 in 2026 instructions)")
# Line 421
ws4b.cell(row=6, column=1, value="421")
ws4b.cell(row=6, column=2, value="Uncollectible associated with Line 419")
# Line 422
ws4b.cell(row=7, column=1, value="422")
ws4b.cell(row=7, column=2, value="Uncollectible associated with Line 420")
# Line 423
ws4b.cell(row=8, column=1, value="423").font = Font(bold=True)
ws4b.cell(row=8, column=2, value="Net USF contribution base (420 422)").font = Font(bold=True)
ws4b.cell(row=8, column=3, value="=C5-C7").font = Font(bold=True)
for row_idx in range(3, 9):
for col in range(1, 6):
ws4b.cell(row=row_idx, column=col).border = BORDER_THIN
ws4b.cell(row=row_idx, column=3).number_format = '"$"#,##0.00'
ws4b.column_dimensions["A"].width = 10
ws4b.column_dimensions["B"].width = 55
ws4b.column_dimensions["C"].width = 18
# ── Block 5 ─────────────────────────────────────────────────────
ws5 = wb.create_sheet("Block 5")
title_suffix = (
" (pre-filled from traffic study — pick orig-state OR billing-state at submission)"
if traffic_study else ""
)
ws5.cell(row=1, column=1,
value=f"Block 5 — Regional Percentage Breakouts + TRS Contribution Base{title_suffix}").font = TITLE_FONT
_set_header_row(ws5, 3, [
"Line", "Region",
"Block 3 (Carrier) %",
"Block 4 (End-User) %",
"Pre-fill: by Orig State %",
"Pre-fill: by Billing State %",
])
for i, (line_num, label) in enumerate(_BLOCK_5_REGIONS, start=4):
ws5.cell(row=i, column=1, value=line_num)
ws5.cell(row=i, column=2, value=label)
is_total = line_num == "510"
if is_total:
ws5.cell(row=i, column=3, value=f"=SUM(C{4}:C{i-1})").font = Font(bold=True)
ws5.cell(row=i, column=4, value=f"=SUM(D{4}:D{i-1})").font = Font(bold=True)
else:
# Pre-fill columns 5 + 6 from the traffic study when available.
# Admin reviewer picks one of them at filing time and copies
# the value into columns C/D for the official submission.
region_key = label.split(":", 1)[0].strip() if ":" in label else label
orig_v = orig_regions.get(region_key)
bill_v = billing_regions.get(region_key)
if orig_v is not None:
ws5.cell(row=i, column=5, value=float(orig_v))
if bill_v is not None:
ws5.cell(row=i, column=6, value=float(bill_v))
# Format all %
for col in (3, 4, 5, 6):
ws5.cell(row=i, column=col).number_format = '0.00"%"'
ws5.cell(row=i, column=col).border = BORDER_THIN
for col in (1, 2):
ws5.cell(row=i, column=col).border = BORDER_THIN
# Line 511 — reseller exclusions
row_offset = 4 + len(_BLOCK_5_REGIONS) + 1
ws5.cell(row=row_offset, column=2, value="Line 511 — Revenues from resellers that do not contribute to USF (included in Block 4-B Line 420 but excluded from TRS/NANPA/LNP/FCC regulatory fee bases)").font = Font(italic=True)
_set_header_row(ws5, row_offset + 1, ["", "Reseller Filer 499 ID", "(a) Total Revenue", "(b) Interstate/Int'l"])
for r in range(row_offset + 2, row_offset + 7):
for col in range(1, 5):
ws5.cell(row=r, column=col).border = BORDER_THIN
ws5.cell(row=r, column=3).number_format = '"$"#,##0.00'
ws5.cell(row=r, column=4).number_format = '"$"#,##0.00'
ws5.column_dimensions["A"].width = 10
ws5.column_dimensions["B"].width = 70
ws5.column_dimensions["C"].width = 20
ws5.column_dimensions["D"].width = 22
# ── Summary ─────────────────────────────────────────────────────
wss = wb.create_sheet("Summary")
wss.cell(row=1, column=1, value="Summary — what feeds into USAC E-File").font = TITLE_FONT
summary_rows = [
("Block 3 Line 315 — Total revenues from resale", "='Block 3'!C" + str(3 + len(_BLOCK_3_LINES) + 1)),
("Block 4-A total — End-user + non-telecom", "='Block 4-A'!C" + str(3 + len(_BLOCK_4A_LINES) + 1)),
("Block 4-B Line 419 — Gross billed revenues", "='Block 4-B'!C4"),
("Block 4-B Line 420 — USF contribution base", "='Block 4-B'!C5"),
("Block 4-B Line 423 — Net USF contribution base", "='Block 4-B'!C8"),
("Block 5 Line 510 — Regional total (must be 100%)", "='Block 5'!C" + str(4 + len(_BLOCK_5_REGIONS) - 1)),
]
for i, (label, formula) in enumerate(summary_rows, start=3):
wss.cell(row=i, column=1, value=label)
cell = wss.cell(row=i, column=2, value=formula)
if "100%" in label or "Line 510" in label:
cell.number_format = '0.00"%"'
else:
cell.number_format = '"$"#,##0.00'
for col in (1, 2):
wss.cell(row=i, column=col).border = BORDER_THIN
wss.column_dimensions["A"].width = 55
wss.column_dimensions["B"].width = 22
# Reorder tabs: README first, Summary last
order = ["README", "Block 3", "Block 4-A", "Block 4-B", "Block 5", "Summary"]
wb._sheets = [wb[name] for name in order if name in wb.sheetnames]
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
wb.save(str(out))
LOG.info("499-A revenue workbook generated: %s", out)
return str(out)

View file

@ -0,0 +1,146 @@
# Do-Not-Originate (DNO) List Enforcement Guide
## Implementation Handbook for Voice Service Providers
**Prepared by Performance West Inc.**
**Effective Date: 2026**
---
## 1. What Is the DNO List?
The FCC Do-Not-Originate (DNO) list is a database of telephone numbers that should **never** appear as the calling party number on the public switched telephone network (PSTN). These numbers are known to be used for illegal robocalling, spoofing, or fraud.
The DNO list is maintained by the **Industry Traceback Group (ITG)**, operated by USTelecom — the Broadband Association, and is distributed to voice service providers and gateway providers for enforcement.
**Regulatory basis:** 47 CFR § 64.6305 (2025 RMD Report & Order, effective February 5, 2026)
---
## 2. Who Must Enforce the DNO List?
All providers who file in the FCC Robocall Mitigation Database:
- **Voice Service Providers** (originating carriers)
- **Gateway Providers** (receiving international traffic)
- **Intermediate Providers** (transit/tandem carriers)
If you have an RMD filing, you are expected to enforce the DNO list.
---
## 3. Implementation Steps
### Step 1: Obtain the DNO List
**Check with your switch/platform provider first.** Many VoIP platforms, hosted PBX providers, and wholesale carriers already include DNO list enforcement as part of their service at no extra cost. If your provider handles this for you, confirm it in writing and reference it in your RMD filing — you may not need to manage the list yourself.
**If your platform does NOT include DNO enforcement,** you must obtain the DNO registry directly:
- **DNO Registry:** https://tracebacks.org/dno-registry/
- This is the official source operated by USTelecom's Industry Traceback Group (ITG)
- The registry provides a downloadable list of numbers that must be blocked
- Register for access and download the current list
- Updates are published regularly — check the registry for the current update schedule
**Other sources that may include DNO enforcement:**
- Call analytics vendors (TransNexus, Neustar, Hiya) often include DNO blocking in their platforms
- Your upstream carrier may already block DNO-listed numbers before they reach your network
### Step 2: Integrate into Your Call Routing
**For SIP/VoIP infrastructure:**
```
# Example: SIP proxy rule (Kamailio/OpenSIPS/FreeSWITCH concept)
# Block calls where the FROM or P-Asserted-Identity matches a DNO number
if (is_in_dno_list($fU)) {
sl_send_reply("603", "Decline - DNO Listed Number");
exit;
}
```
**Implementation options by platform:**
| Platform | Method |
|---|---|
| FreeSWITCH | Load DNO list into mod_blacklist or use a Lua/Python script in dialplan |
| Kamailio | Hash table lookup in route block, loaded from DB or file |
| Asterisk | Use a database-backed dialplan function (ODBC or AstDB) |
| Metaswitch | Policy rule in call control configuration |
| BroadSoft/Cisco BroadWorks | System-level origination blocking rule |
| Cloud UCaaS | Contact your UCaaS vendor — they should enforce DNO on your behalf |
### Step 3: Apply to All Call Origination Points
The DNO check must be applied at **every point where calls enter your network**:
1. **Customer-facing SIP trunks** — check the calling number before routing
2. **Gateway interfaces** — check foreign-originated traffic at ingress
3. **Wholesale interconnections** — check traffic received from downstream carriers
4. **SIP registration** — optionally prevent registration of DNO-listed numbers as extensions
### Step 4: Establish an Update Schedule
| Frequency | Action |
|---|---|
| **Daily** (recommended) | Download latest DNO list, update blocking rules |
| **Immediately** | If you receive a traceback request identifying a DNO number you're originating, block within 4 hours |
| **Monthly** | Audit your blocking logs to confirm DNO enforcement is working |
### Step 5: Log and Report
Maintain logs of:
- DNO list version/date loaded
- Number of calls blocked per day due to DNO match
- Any exceptions or overrides (there should be none for DNO numbers)
- Date and time of each list update
These logs demonstrate compliance if the FCC or ITG requests evidence of enforcement.
---
## 4. What to Do If a Customer Complains
If a legitimate customer reports their number is being blocked:
1. Verify the number against the current DNO list
2. If the number IS on the DNO list, the customer must contact the ITG to request removal
3. Do NOT create exceptions to bypass DNO blocking — this violates your RMD certification
4. Document the complaint and your response
---
## 5. Documenting DNO Enforcement in Your RMD Filing
Your RMD certification and/or robocall mitigation plan (Exhibit A) should include language such as:
> "[Company Name] immediately blocks any numbers identified on the FCC Do-Not-Originate (DNO) list. DNO list updates are applied daily to prevent origination of calls from numbers known to be used for illegal robocalling. Blocking is enforced at all network ingress points including customer SIP trunks, gateway interfaces, and wholesale interconnections."
---
## 6. Common Mistakes to Avoid
| Mistake | Consequence |
|---|---|
| Not mentioning DNO in your RMD filing | Filing flagged as deficient |
| Updating the list monthly instead of daily | Stale data allows blocked numbers through |
| Only blocking on one trunk, not all ingress | Partial enforcement = non-compliance |
| Creating manual exceptions for "known good" customers | Undermines the entire program |
| Not logging blocked calls | Cannot demonstrate compliance to FCC/ITG |
---
## 7. Resources
- **Industry Traceback Group:** https://tracebacks.org
- **USTelecom:** https://www.ustelecom.org
- **FCC RMD Portal:** https://apps.fcc.gov/rmd/
- **47 CFR § 64.6305:** https://www.ecfr.gov/current/title-47/chapter-I/subchapter-B/part-64/subpart-CC
- **2025 RMD Report & Order:** FCC 25-6 (effective February 5, 2026)
---
*This guide is provided for informational purposes as part of your RMD filing service. It is not legal advice. Consult with your regulatory counsel for implementation decisions specific to your network.*
*Performance West Inc. — performancewest.net — 1-888-411-0383*

View file

@ -0,0 +1,184 @@
# Know Your Customer (KYC) Procedures Guide
## Implementation Handbook for Voice Service Providers
**Prepared by Performance West Inc.**
**Effective Date: 2026**
---
## 1. What Are KYC Procedures Under the RMD?
The FCC's 2025 RMD Report & Order requires all voice service providers to implement **Know Your Customer (KYC) procedures** as part of their robocall mitigation program. KYC is the process of verifying the identity and legitimacy of customers before providing them with voice service — and monitoring them on an ongoing basis.
**Regulatory basis:** 47 CFR § 64.1200(n)(4), reinforced by the 2025 RMD Report & Order (FCC 25-6)
---
## 2. Required KYC Elements
Your KYC program must include:
### A. Information Collection at Signup
Collect the following from every new customer before activating service:
| Required Information | Purpose |
|---|---|
| Full legal name (individual or entity) | Identity verification |
| Physical business address (no P.O. boxes for high-volume/toll-free) | Location verification |
| Business identification (EIN/tax ID, or last 4 SSN for individuals) | Tax identity confirmation |
| Government-issued photo ID | Identity authentication |
| Business website or description of legitimate business purpose | Legitimacy assessment |
| Contact phone and email | Communication channel |
### B. Verification Steps
For each new customer, perform these checks:
1. **Cross-reference business name + EIN** against your state's business registry or IRS database
2. **Verify address** via USPS Address Verification or a third-party source (LexisNexis, Dun & Bradstreet)
3. **Authenticate photo ID** — confirm it is genuine, not expired, and the name matches (see recommended tool below)
4. **Open-source search** — search the customer name and principals for:
- Prior association with illegal robocalling
- Inclusion on the ITG's known bad-actor traceback list
- FCC enforcement actions or complaints
- Spoofing or fraud complaints
#### Recommended: Stripe Identity for ID Verification
For automated, reliable identity verification, we recommend **Stripe Identity** (https://stripe.com/identity). It provides:
- **Government-issued ID document verification** — authenticates the ID is real, not expired, and not tampered with
- **Selfie matching with liveness detection** — confirms the person holding the ID is the person on it
- **SSN-based ID number lookup** (US only) — cross-references against authoritative databases
**Pricing:**
- **First 50 verifications: FREE** (included with any Stripe account)
- **$1.50 per verification** after the free tier
- **Volume discounts** available for 2,000+ verifications/month (contact Stripe)
This is significantly cheaper than traditional KYC vendors and integrates directly into your customer onboarding flow via API or hosted verification page. Most small-to-mid carriers will stay within the free tier (50 new customers per billing cycle). At $1.50 each after that, verifying 100 customers costs just $75.
**Integration:** Stripe Identity can be embedded as a link in your customer signup form — the customer clicks a link, takes a photo of their ID and a selfie, and Stripe returns a pass/fail result to your system within seconds. No manual review needed for passing verifications.
### C. Red-Flag Review
Trigger enhanced due diligence when any of the following occur:
- Customer is unwilling or unable to provide complete KYC information
- Discrepancies between provided information and public records
- Use of privacy-protected or anonymous registration services
- Usage patterns inconsistent with stated business purpose
- Prior complaints, tracebacks, or enforcement actions linked to the customer
- Request for unusually high call volumes relative to stated business size
### D. Ongoing Monitoring
- **Annual re-vetting** for all customers (minimum)
- **Immediate re-review** upon complaints, traceback requests, or anomalous traffic patterns
- **High-volume/toll-free customers:** quarterly review
---
## 3. Implementation Steps
### Step 1: Create Your KYC Intake Form
Build a customer onboarding form (paper or digital) that collects all required fields. Store responses in your CRM or customer database.
**Recommended fields:**
```
- Legal entity name
- DBA / trade name
- Entity type (LLC, Corp, Sole Prop, etc.)
- EIN or Tax ID
- State of formation
- Physical address (street, city, state, zip)
- Mailing address (if different)
- Primary contact name, title, phone, email
- Government-issued ID (upload or in-person)
- Business website URL
- Description of intended use of voice services
- Expected monthly call volume
- Authorized signatory for service agreement
```
### Step 2: Build Your Verification Checklist
For each new customer, a team member should complete:
- [ ] Business name verified against state registry
- [ ] EIN verified (IRS EIN verification letter or cross-reference)
- [ ] Address validated via USPS or third-party
- [ ] Photo ID reviewed and authenticated
- [ ] Web search completed for bad-actor associations
- [ ] ITG traceback list checked (if available)
- [ ] FCC ECFS searched for complaints against this entity
- [ ] No red flags identified (or enhanced due diligence completed)
- [ ] Acceptable Use Policy signed by customer
- [ ] Service activated
### Step 3: Acceptable Use Policy
Every customer must sign an Acceptable Use Policy (AUP) that includes:
- Prohibition of illegal robocalling, spoofing, and fraud
- Prohibition of originating calls to/from DNO-listed numbers
- Agreement to cooperate with traceback requests
- Right to immediately suspend service for violations
- Requirement to notify you of changes to business information
### Step 4: Set Up Ongoing Monitoring
Configure your systems to flag:
- Customers exceeding their stated call volume by 2x or more
- Sudden spikes in short-duration calls (potential robocall signature)
- High Answer-Seizure Ratio (ASR) anomalies
- Complaints received from downstream carriers or end users
- Traceback requests from ITG or law enforcement
### Step 5: Document Your Process
Write an internal SOP document covering:
- Who performs KYC reviews (role/title)
- How records are stored and for how long
- What triggers enhanced due diligence
- How to handle customer refusals
- Escalation procedures for red-flag findings
---
## 4. Documenting KYC in Your RMD Filing
Your RMD certification (Exhibit A) should include:
> "[Company Name] conducts internal Know Your Customer (KYC) procedures for all customers. At account signup or upon any material change in service usage, we require and collect: full legal name, physical business address, business identification (EIN or tax ID), government-issued photo ID, and a description of legitimate business purpose. We cross-reference business information against state registries, validate addresses via USPS, verify photo ID authenticity, and conduct open-source searches for prior robocalling associations. Enhanced due diligence is triggered when red flags are identified."
---
## 5. Common Mistakes to Avoid
| Mistake | Consequence |
|---|---|
| No KYC section in RMD filing | Filing flagged as deficient under 2026 requirements |
| Collecting info but not verifying it | Non-compliance — verification is the key requirement |
| No ongoing monitoring after signup | Fails the "continuous compliance" standard |
| No AUP or terms of service | Cannot enforce against abusive customers |
| Storing KYC data without security measures | Potential data breach liability |
---
## 6. Resources
- **FCC 47 CFR § 64.1200(n)(4):** KYC requirements for voice service providers
- **ITG (Industry Traceback Group):** https://tracebacks.org
- **FCC ECFS (complaints search):** https://www.fcc.gov/ecfs/
- **USPS Address Verification:** https://tools.usps.com/zip-code-lookup.htm
- **IRS EIN Verification:** https://www.irs.gov/businesses/small-businesses-self-employed/employer-id-numbers
---
*This guide is provided for informational purposes as part of your RMD filing service. It is not legal advice.*
*Performance West Inc. — performancewest.net — 1-888-411-0383*

View file

@ -0,0 +1,139 @@
# Material Change Update Procedures Guide
## Implementation Handbook for Voice Service Providers
**Prepared by Performance West Inc.**
**Effective Date: 2026**
---
## 1. What Is the Material Change Requirement?
Under the FCC's 2025 RMD Report & Order (effective February 5, 2026), all providers with an RMD filing must update their certification within **10 business days** of any material change to their operations, ownership, or filing information.
Failure to update within 10 business days can result in a **$1,000 per day forfeiture**.
**Regulatory basis:** 47 CFR § 64.6305, 2025 RMD Report & Order (FCC 25-6)
---
## 2. What Counts as a Material Change?
| Category | Examples |
|---|---|
| **Ownership** | Change in controlling interest, merger, acquisition, new parent company |
| **Corporate identity** | Legal name change, DBA change, new EIN |
| **Contact information** | New robocall mitigation contact person, email, phone, address |
| **STIR/SHAKEN status** | Change from partial to full implementation, switch to a different STI-CA, loss of SPC token |
| **Provider classification** | Adding gateway operations, ceasing to be a VSP, becoming an intermediate provider |
| **Upstream provider** | Changing the upstream carrier that provides STIR/SHAKEN signing |
| **Robocall mitigation program** | Significant changes to KYC procedures, analytics vendors, blocking policies |
| **Trade names / DBAs** | Adding or removing names under which you operate |
---
## 3. Implementation Steps
### Step 1: Designate a Compliance Officer
Assign one person (and a backup) as the **RMD Compliance Officer** responsible for:
- Monitoring for material changes
- Initiating the update process within 10 business days
- Maintaining an audit trail of updates
**Recommended:** The same person who is listed as the RMD contact on your filing.
### Step 2: Create a Material Change Checklist
Post this checklist where operations, legal, and management teams can see it:
**When ANY of the following occur, notify the RMD Compliance Officer immediately:**
- [ ] Company name or DBA changed
- [ ] Ownership or controlling interest changed
- [ ] New parent company, merger, or acquisition
- [ ] RMD contact person changed (name, email, phone)
- [ ] Principal office address changed
- [ ] STIR/SHAKEN certificate authority changed
- [ ] SPC token renewed, revoked, or transferred
- [ ] Upstream provider changed
- [ ] Provider type changed (added/removed VSP/gateway/intermediate)
- [ ] Robocall mitigation program materially revised
- [ ] New analytics vendor deployed or existing vendor discontinued
- [ ] Trade names added or removed
### Step 3: Establish an Internal Notification Process
Create a simple workflow:
```
Change occurs (e.g., new upstream provider signed)
Department head notifies RMD Compliance Officer (email/ticket)
<20><><EFBFBD>
Compliance Officer logs the change in the tracking spreadsheet
Within 5 business days: prepare updated RMD filing content
Within 10 business days: submit update to FCC RMD portal
Confirm update accepted, save confirmation screenshot
```
### Step 4: Update the RMD Portal
To submit an update:
1. Log in to the FCC RMD portal at https://apps.fcc.gov/rmd/ (MFA required since Feb 5, 2026)
2. Navigate to your existing certification
3. Click "Update" or "Edit"
4. Modify the relevant fields
5. Re-upload your certification letter/Exhibit A if the mitigation plan changed
6. Submit and save the confirmation page
**Or:** Contact Performance West — we can submit the update on your behalf as your authorized filing agent.
### Step 5: Maintain an Audit Trail
Keep a log of all material changes and RMD updates:
| Date | Change Description | Notified By | Updated in RMD | Confirmation # |
|---|---|---|---|---|
| 2026-03-15 | New upstream provider (ABC Telecom) | VP Engineering | 2026-03-18 | RMD-UPD-12345 |
| 2026-04-01 | Contact email changed to new@company.com | HR | 2026-04-03 | RMD-UPD-12346 |
---
## 4. Documenting Material Change Procedures in Your RMD Filing
Your RMD certification should include:
> "[Company Name] updates its RMD certification and CORES registration within 10 business days of any material change, including but not limited to changes in ownership, contacts, STIR/SHAKEN posture, upstream provider, or trade names, per 47 CFR § 64.6305. A designated compliance officer monitors for material changes and maintains an audit trail of all updates."
---
## 5. Penalties for Non-Compliance
| Violation | Penalty |
|---|---|
| Failure to update within 10 business days | $1,000/day forfeiture |
| False or inaccurate information in filing | $10,000 base forfeiture |
| Failure to maintain compliant certification | Removal from RMD (downstream carriers must block your traffic within 30 days) |
---
## 6. Common Mistakes to Avoid
| Mistake | Consequence |
|---|---|
| No material change language in RMD filing | Filing flagged as deficient |
| Updating only annually during recertification | Misses the 10-day requirement for mid-year changes |
| No internal notification process | Changes happen without anyone updating the RMD |
| Updating the filing but not the Exhibit A | Inconsistency between certification and plan |
| No audit trail | Cannot demonstrate timely compliance if audited |
---
*This guide is provided for informational purposes as part of your RMD filing service. It is not legal advice.*
*Performance West Inc. — performancewest.net — 1-888-411-0383*

View file

@ -0,0 +1,137 @@
# Traceback Response Procedures Guide
## Implementation Handbook for Voice Service Providers
**Prepared by Performance West Inc.**
**Effective Date: 2026**
---
## 1. What Is a Traceback Request?
A traceback is the process of identifying the originating source of an illegal robocall by tracing the call path backward through the network — from the terminating carrier, through intermediate/transit providers, back to the originating carrier and ultimately the customer who placed the call.
Traceback requests are issued by:
- **Industry Traceback Group (ITG)** — operated by USTelecom
- **FCC Enforcement Bureau**
- **State attorneys general**
- **Federal and state law enforcement**
**Your obligation:** Respond fully and completely within **24 hours**.
**Regulatory basis:** 47 CFR § 64.6305(d)(2)(iii), (e)(2)(iii), (f)(2)(iii)
---
## 2. Implementation Steps
### Step 1: Designate a 24/7 Traceback Contact
You MUST have someone available to respond to traceback requests within 24 hours, including weekends and holidays.
**Recommended structure:**
- **Primary contact:** Your RMD compliance officer or NOC manager
- **Backup contact:** A second person with access to CDR systems
- **Email alias:** Create a dedicated email like traceback@yourcompany.com that forwards to both
- **Phone:** A direct line or on-call number (not a general IVR)
Register your traceback contact with the ITG at https://tracebacks.org
### Step 2: Ensure CDR Access
Your traceback contact must be able to:
- Search Call Detail Records (CDRs) by called number, calling number, and date/time
- Identify which customer or trunk group originated a specific call
- Export relevant CDR data for the requesting party
- Access records going back at least 18 months
**Systems to prepare:**
- CDR database or data warehouse with search capability
- SIP/SS7 log access (if available)
- Customer account lookup by trunk/SIP registration
### Step 3: Create a Traceback Response Template
When you receive a traceback request, respond with:
```
TRACEBACK RESPONSE
Date: [today]
Request Reference: [ITG/FCC reference number]
Responding Provider: [Your company name]
FRN: [Your FRN]
Contact: [Name, email, phone]
CALL DETAILS REQUESTED:
Called Number: [number from request]
Calling Number: [number from request]
Date/Time: [from request]
FINDINGS:
Call found in our records: YES / NO
Originating customer/trunk: [customer name or trunk ID]
Customer account number: [if applicable]
Upstream source (if transit): [provider name, trunk ID]
CDR excerpt attached: YES / NO
ACTION TAKEN:
[e.g., "Customer notified of violation", "Traffic blocked",
"Account suspended pending investigation", "Referred to upstream provider"]
Signed: [Name, Title]
```
### Step 4: Establish Response SLA
| Timeline | Action |
|---|---|
| **01 hour** | Acknowledge receipt of traceback request |
| **14 hours** | Search CDRs, identify the source |
| **412 hours** | Prepare response with CDR evidence |
| **Within 24 hours** | Send complete response to the requesting party |
| **Immediately** | If the source is a known bad actor, block the traffic |
### Step 5: Take Enforcement Action
After identifying the source:
1. **Notify the customer** that they are the subject of a traceback
2. **Review the customer's account** for patterns of abuse
3. **If abuse is confirmed:** suspend or terminate service per your AUP
4. **If the call was transit traffic:** forward the traceback to your upstream provider
5. **Document everything** — enforcement actions, customer communications, blocking orders
---
## 3. Documenting Traceback Procedures in Your RMD Filing
Your RMD certification should include:
> "[Company Name] commits to respond fully and completely to all traceback requests from the Commission, civil and criminal law enforcement, and the industry traceback consortium, and to do so within 24 hours of receipt. [Company Name] cooperates with the Industry Traceback Group operated by USTelecom and provides requested call detail records and tracing information necessary to identify the origin of suspected illegal robocalls."
---
## 4. What Happens If You Don't Respond
| Failure | Consequence |
|---|---|
| No response within 24 hours | Reported to FCC as non-responsive provider |
| Pattern of non-response | FCC enforcement action, potential RMD removal |
| RMD removal | Downstream carriers must block your traffic within 30 days |
---
## 5. Common Mistakes to Avoid
| Mistake | Consequence |
|---|---|
| No 24/7 contact registered with ITG | Traceback requests go unanswered |
| CDRs not retained long enough | Cannot trace historical calls |
| Responding to ITG but not taking action against the customer | FCC views this as insufficient mitigation |
| No traceback language in RMD filing | Filing flagged as deficient |
---
*This guide is provided for informational purposes as part of your RMD filing service. It is not legal advice.*
*Performance West Inc. — performancewest.net — 1-888-411-0383*

View file

@ -0,0 +1,324 @@
"""
Generate the NECA Company Code (OCN) Request Form packet.
NECA has not revised the Company Code Request Form since August 2023;
the current form is still in use in 2026. Standard processing is
$550 / 10 business days; expedited is $675 / 5 business days. Payment is
made directly to NECA (Performance West does not collect the NECA fee
separately it is a pass-through).
Required supporting documentation per NECA (varies by service category):
* Legal document proving existence (Articles of Incorporation with
state seal, state registration, etc.) always required
* For CLEC, ULEC, CAP, Local Reseller: state PUC certification
* For Interexchange Carrier: state PUC approval where applicable
* For ETHX: signed customer contracts + service description
* For IPES (VoIP): signed interconnection agreements (or an approved
interconnection order) + end-user contractual agreements or invoices
* For Wireless/PCS: FCC radio/PCS license
* For Wireless/PCS resellers: interconnection agreement with carrier
This generator produces:
1. A cover letter introducing the request on Performance West letterhead
2. A filled-in version of the 2-page NECA form (replicating the fields;
the customer prints, signs, and submits OR we fax/email on their behalf)
Usage:
from scripts.document_gen.templates.ocn_request_form_generator import (
generate_ocn_request_packet,
)
path = generate_ocn_request_packet(
entity_name="Falcon Broadband LLC",
service_category="IPES",
operating_states=["CA","NY","TX"],
expedited=True,
...,
)
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.ocn_request")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — OCN request generation unavailable")
Document = None # type: ignore[assignment,misc]
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
BODY_SIZE = Pt(10) if Document else None
HEADING_SIZE = Pt(12) if Document else None
# NECA contact block — unchanged since 2023 form revision.
NECA_ADDRESS = (
"NECA Company Code Administrator\n"
"60 Columbia Road, Building A \u2014 2nd Floor\n"
"Morristown, NJ 07960\n"
"Phone: 973-884-8105 | Fax: 973-993-1063 | Email: ccfees@neca.org"
)
NECA_FEES = {
"standard": {"cents": 55000, "processing_days": 10, "label": "Standard (10 business days)"},
"expedited": {"cents": 67500, "processing_days": 5, "label": "Expedited (5 business days)"},
}
SERVICE_CATEGORIES = {
"CAP": "Competitive Access Provider",
"ETHX": "Ethernet Exchange",
"CLEC": "Competitive Local Exchange Carrier",
"IC": "Interexchange Carrier",
"IPES": "Internet Protocol Enabled Services (VoIP)",
"LRSL": "Local Exchange Reseller",
"PCS": "Personal Communications Service",
"PCSR": "PCS Reseller",
"ULEC": "Unbundled Local Exchange Carrier",
"WIRE": "Wireless Carrier",
"WRSL": "Wireless Reseller",
}
# Required documentation by category. Lifted directly from the NECA form
# (page 2, "REQUIRED DOCUMENTATION" section).
REQUIRED_DOCS_BY_CATEGORY = {
"IPES": [
"Legal document (e.g., Articles of Incorporation with state seal) as proof of existence.",
"Signed interconnection agreements (or evidence of an interconnection order pursuant to an approved tariff).",
"Signed contractual agreements OR an invoice with end-user customers showing proof of customer.",
"Detailed description of the type of IPES service being provided including areas served.",
],
"CLEC": [
"Legal document (e.g., Articles of Incorporation with state seal) as proof of existence.",
"Copy of the certification by the state Public Utility Commission.",
],
"ULEC": [
"Legal document proving existence.",
"Copy of the certification by the state Public Utility Commission.",
],
"CAP": [
"Legal document proving existence.",
"Copy of the certification by the state Public Utility Commission.",
],
"LRSL": [
"Legal document proving existence.",
"Copy of the certification by the state Public Utility Commission.",
],
"IC": [
"Legal document proving existence.",
"State PUC approval where applicable.",
],
"ETHX": [
"Legal document proving existence.",
"Proof of service and customers (contractual agreements + service description).",
],
"WIRE": [
"Legal document proving existence.",
"Copy of the company's FCC radio/PCS license.",
],
"PCS": [
"Legal document proving existence.",
"Copy of the company's FCC radio/PCS license.",
],
"WRSL": [
"Legal document proving existence.",
"Copy of the interconnection agreement with the wireless carrier.",
],
"PCSR": [
"Legal document proving existence.",
"Copy of the interconnection agreement with the wireless carrier.",
],
}
def _heading(doc, text: str, level: int = 1) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12 if level == 1 else 8)
p.paragraph_format.space_after = Pt(4)
run = p.add_run(text)
run.bold = True
run.font.size = HEADING_SIZE if level == 1 else Pt(11)
run.font.color.rgb = NAVY
def _body(doc, text: str, bold: bool = False) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(6)
run = p.add_run(text)
run.font.size = BODY_SIZE
run.bold = bold
def _field_line(doc, label: str, value: str) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(3)
run_l = p.add_run(f"{label}: ")
run_l.bold = True
run_l.font.size = BODY_SIZE
run_v = p.add_run(value or "_______________________")
run_v.font.size = BODY_SIZE
def generate_ocn_request_packet(
# Entity identity
entity_name: str,
legal_entity_full: str = "",
# Requestor (the person filling out the form — defaults to PW)
requestor_name: str = "Justin Hannah",
requestor_employer: str = "Performance West Inc.",
requestor_voice: str = "888-411-0383",
requestor_fax: str = "",
requestor_mailing_address: str = "30 N Gould St, Ste N, Sheridan, WY 82801",
requestor_email: str = "filings@performancewest.net",
# Company contact (client-side)
company_contact_name: str = "",
company_contact_voice: str = "",
company_contact_fax: str = "",
company_contact_email: str = "",
company_contact_address: str = "",
# Service category — key from SERVICE_CATEGORIES (default IPES for VoIP)
service_category: str = "IPES",
operating_states: list[str] | None = None,
expedited: bool = False,
# Output
output_path: str = "/tmp/ocn_request_packet.docx",
) -> Optional[str]:
"""Generate the NECA OCN request packet DOCX (cover letter + filled form)."""
if Document is None:
LOG.error("python-docx not installed")
return None
operating_states = operating_states or []
category = service_category.upper()
if category not in SERVICE_CATEGORIES:
LOG.warning(
"generate_ocn_request_packet: unknown service_category %r, "
"defaulting to IPES",
service_category,
)
category = "IPES"
fee = NECA_FEES["expedited"] if expedited else NECA_FEES["standard"]
legal_entity_full = legal_entity_full or entity_name
today = datetime.now().strftime("%B %d, %Y")
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
# ── Page 1: Cover letter ────────────────────────────────────────
title_p = doc.add_paragraph()
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_run = title_p.add_run("NECA Company Code (OCN) Request Packet")
title_run.font.size = Pt(14)
title_run.bold = True
title_run.font.color.rgb = NAVY
_body(doc, f"Date: {today}")
_body(doc, "")
_body(doc, "To:")
_body(doc, NECA_ADDRESS)
_body(doc, "")
_body(doc, (
f"Dear NECA Company Code Administrator,\n\n"
f"Please find enclosed a Company Code Request Form and supporting "
f"documentation for {legal_entity_full}. We are submitting this "
f"request in the {category} \u2014 "
f"{SERVICE_CATEGORIES[category]} category."
))
_body(doc, (
f"Processing type requested: {fee['label']}.\n"
f"Payment of ${fee['cents']/100:.2f} will accompany this filing."
))
if operating_states and category in ("CLEC", "ULEC"):
_body(doc, (
f"States of operation (multiple codes will be assigned for "
f"CLEC/ULEC): {', '.join(operating_states)}"
))
_body(doc, (
"If you have questions or need additional information, please "
f"contact me directly at {requestor_voice} or {requestor_email}."
))
_body(doc, "")
_body(doc, "Sincerely,")
_body(doc, "")
_body(doc, "")
_body(doc, requestor_name, bold=True)
_body(doc, requestor_employer)
doc.add_page_break()
# ── Page 2: Company Code Request Form (replicated fields) ──────
_heading(doc, "COMPANY CODE REQUEST FORM")
_body(doc, f"Issued: August 2023 | Date of Request: {today}")
_heading(doc, "REQUESTOR INFORMATION", level=2)
_field_line(doc, "Requestor's Name", requestor_name)
_field_line(doc, "Employer", requestor_employer)
_field_line(doc, "Mailing Address", requestor_mailing_address)
_field_line(doc, "Voice Number", requestor_voice)
_field_line(doc, "Fax Number", requestor_fax)
_field_line(doc, "Email Address", requestor_email)
_body(doc, (
"Note: This contact will also be listed in iconectiv's routing "
"products as \"Agent for Service of Process\". To use a different "
"contact, notify iconectiv TruOps TRA at 732-699-6700."
))
_heading(doc, "COMPANY INFORMATION", level=2)
_field_line(doc, "Company Name / Full Legal Entity Name", legal_entity_full)
_field_line(doc, "Company Contact", company_contact_name)
_field_line(doc, "Voice Number", company_contact_voice)
_field_line(doc, "Fax Number", company_contact_fax)
_field_line(doc, "Email Address", company_contact_email)
_field_line(doc, "Company Contact Mailing Address", company_contact_address)
_heading(doc, "SERVICE CATEGORY REQUESTED", level=2)
for code, label in SERVICE_CATEGORIES.items():
mark = "\u2611" if code == category else "\u2610"
states = ""
if code == category and operating_states and code in ("CLEC", "ULEC"):
states = " Operating States: " + ", ".join(operating_states)
expedited_mark = "\u2611 EXPEDITED" if (code == category and expedited) else ""
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(2)
run = p.add_run(f" {mark} {code} \u2014 {label}{states} {expedited_mark}")
run.font.size = BODY_SIZE
_heading(doc, "REQUIRED DOCUMENTATION (attach to submission)", level=2)
for doc_item in REQUIRED_DOCS_BY_CATEGORY.get(category, []):
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.space_after = Pt(3)
p.clear()
run = p.add_run(doc_item)
run.font.size = BODY_SIZE
_heading(doc, "PRICING AND PAYMENT", level=2)
_body(doc, (
f"Submit ${fee['cents']/100:.2f} for this "
f"{'expedited' if expedited else 'standard'} request. Code requests "
f"are processed within {fee['processing_days']} business days of "
f"receipt of all required documentation including payment."
))
_heading(doc, "SUBMISSION", level=2)
_body(doc, (
"Fax the completed form + documentation to +1 973-993-1063, OR "
"email to ccfees@neca.org, OR mail to:"
))
_body(doc, NECA_ADDRESS)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("NECA OCN request packet generated: %s", out)
return str(out)

View file

@ -0,0 +1,214 @@
"""
Generate the FCC Form 499-A Reseller Certification Attestation.
Produces a DOCX that a filer sends to its reseller customer for the
reseller's authorized officer to sign. The signed attestation
establishes that the purchased service is being resold into a
telecommunications or interconnected VoIP offering, and that USF
contribution is flowing (per 2026 Form 499-A Section IV.C.4).
The sample certification text is reproduced verbatim from
``site/src/lib/fcc_constants.ts::RESELLER_CERTIFICATION_SAMPLE_TEXT``
and mirrored in the constant ``RESELLER_CERTIFICATION_SAMPLE_TEXT``
below. If upstream source text changes, update both locations.
Usage:
from scripts.document_gen.templates.reseller_cert_attestation_generator import (
generate_reseller_cert_attestation,
)
path = generate_reseller_cert_attestation(
output_path="/tmp/reseller_cert.docx",
filer_legal_name="Acme Telco LLC",
filer_filer_id_499="812345",
reseller_legal_name="Beta Reseller Corp",
reseller_filer_id_499="812999",
reporting_year=2025,
)
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.reseller_cert_attestation")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
except ImportError:
LOG.warning("python-docx not installed — reseller cert attestation unavailable")
Document = None # type: ignore[assignment,misc]
_NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
# Verbatim from site/src/lib/fcc_constants.ts (RESELLER_CERTIFICATION_SAMPLE_TEXT).
RESELLER_CERTIFICATION_SAMPLE_TEXT = (
"I certify under penalty of perjury that the company is purchasing "
"service(s) for resale, at least in part, and that the company is "
"incorporating the purchased services into its own offerings which "
"are, at least in part, assessable U.S. telecommunications or "
"interconnected Voice over Internet Protocol services. I also "
"certify under penalty of perjury that the company either directly "
"contributes or has a reasonable expectation that another entity in "
"the downstream chain of resellers directly contributes to the "
"federal universal service support mechanisms on the assessable "
"portion of revenues from offerings that incorporate the purchased "
"services."
)
def _sp(p, after=6, before=0):
p.paragraph_format.space_after = Pt(after)
if before:
p.paragraph_format.space_before = Pt(before)
def _h(doc, text):
p = doc.add_paragraph(); r = p.add_run(text)
r.font.size = Pt(12); r.bold = True; r.font.color.rgb = _NAVY
_sp(p, after=4, before=8)
def _b(doc, text, bold=False, size=10):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run(text); r.font.size = Pt(size); r.bold = bold
_sp(p, after=6)
def generate_reseller_cert_attestation(
output_path: str,
filer_legal_name: str,
filer_filer_id_499: str,
reseller_legal_name: str,
reseller_filer_id_499: str = "",
reporting_year: int = 0,
effective_date: str = "",
filer_contact_name: str = "",
filer_contact_email: str = "",
filer_contact_phone: str = "",
**_: dict,
) -> Optional[str]:
"""
Produce the Reseller Certification Attestation DOCX for signature
by the reseller's authorized officer.
"""
if Document is None:
LOG.error("python-docx not installed")
return None
if reporting_year == 0:
reporting_year = datetime.now().year
effective = effective_date or datetime.now().strftime("%B %d, %Y")
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)
# Title
tp = doc.add_paragraph(); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
t = tp.add_run("Reseller Certification Attestation")
t.font.size = Pt(15); t.bold = True; t.font.color.rgb = _NAVY
_sp(tp, after=2)
sp = doc.add_paragraph(); sp.alignment = WD_ALIGN_PARAGRAPH.CENTER
s = sp.add_run(
f"FCC Form 499-A Section IV.C.4 \u2014 Reporting Year {reporting_year}"
)
s.font.size = Pt(10); s.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
_sp(sp, after=8)
# ── 1. Parties ────────────────────────────────────────────────
_h(doc, "1. Parties")
_b(doc, (
f"Filer (upstream wholesaler): {filer_legal_name}"
+ (f" | Filer 499 ID: {filer_filer_id_499}" if filer_filer_id_499 else "")
))
_b(doc, (
f"Reseller (purchaser): {reseller_legal_name}"
+ (f" | Filer 499 ID: {reseller_filer_id_499}"
if reseller_filer_id_499 else " | Filer 499 ID: _______________")
))
_b(doc, f"Effective Reporting Year: {reporting_year}")
_b(doc, f"Date of Attestation: {effective}")
# ── 2. Purpose ────────────────────────────────────────────────
_h(doc, "2. Purpose")
_b(doc, (
f"Pursuant to Section IV.C.4 of the FCC Form 499-A Instructions "
f"({reporting_year}), {filer_legal_name} is required to obtain from "
f"{reseller_legal_name} a signed certification that the services "
f"purchased by the reseller are being incorporated into assessable "
f"U.S. telecommunications or interconnected Voice over Internet "
f"Protocol offerings, and that federal universal-service "
f"contributions are flowing on those resold services. This "
f"Attestation satisfies that requirement."
))
# ── 3. Certification Language (verbatim) ──────────────────────
_h(doc, "3. Certification")
_b(doc, RESELLER_CERTIFICATION_SAMPLE_TEXT)
# ── 4. Annual Renewal Notice ──────────────────────────────────
_h(doc, "4. Annual Renewal")
_b(doc, (
"This Attestation is effective for the reporting year stated "
f"above and must be renewed annually. {filer_legal_name} will "
f"request an updated, signed Attestation from {reseller_legal_name} "
f"on or about January 1 of each subsequent reporting year. A "
f"current signed Attestation must be on file at the time "
f"{filer_legal_name} submits its Form 499-A for the reporting "
f"year."
))
# ── 5. Reseller Authorized Officer Signature ──────────────────
_h(doc, "5. Signature — Reseller Authorized Officer")
_b(doc, (
f"By signing below, the undersigned officer of {reseller_legal_name} "
f"certifies that he or she is an authorized officer of the company, "
f"has personal knowledge of the matters certified above, and "
f"executes this Attestation on behalf of the company."
))
# Blank signature block
sig = doc.add_paragraph(); sig.add_run("_" * 55).font.size = Pt(10)
_sp(sig, after=2)
nm = doc.add_paragraph()
nm.add_run("Name (printed): ").font.size = Pt(10)
nm.add_run("_" * 40).font.size = Pt(10)
_sp(nm, after=4)
tt = doc.add_paragraph()
tt.add_run("Title: ").font.size = Pt(10)
tt.add_run("_" * 48).font.size = Pt(10)
_sp(tt, after=4)
co = doc.add_paragraph()
co.add_run("Company: ").font.size = Pt(10)
co.add_run(reseller_legal_name).font.size = Pt(10)
_sp(co, after=4)
dt = doc.add_paragraph()
dt.add_run("Date: ").font.size = Pt(10)
dt.add_run("_" * 20).font.size = Pt(10)
_sp(dt, after=10)
# ── 6. Filer Contact (for returning the signed form) ──────────
_h(doc, "6. Return Signed Attestation To")
_b(doc, filer_legal_name)
if filer_contact_name:
_b(doc, f"Attention: {filer_contact_name}")
if filer_contact_email:
_b(doc, f"Email: {filer_contact_email}")
if filer_contact_phone:
_b(doc, f"Phone: {filer_contact_phone}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
LOG.info("Reseller Certification Attestation generated: %s", out)
return str(out)

View file

@ -0,0 +1,602 @@
"""
Generate the Robocall Mitigation Plan (Exhibit A to RMD filing).
Follows the Performance West canonical 7-section outline observed across
all example filings in docs/examplefilings/ (Engage, Franklin, Zingo,
Syntracom, VoIPFlo, Fortel). This is a deterministic, template-driven
generator no LLM with role-specific paragraphs for each carrier
category.
2026 Updates (2025 RMD R&O effective Feb 5, 2026):
* References recertification due March 2, 2026 (March 1 is Sunday)
* Multi-factor authentication on RMD portal
* $10,000 false-info forfeiture and $1,000/day late-update forfeiture
* 10-business-day material-change update deadline
* Explicit DNO list blocking + 4-hour high-risk alert review SLA
* Penalty-of-perjury declaration at the end
Canonical section outline (mirrors docs/examplefilings/):
Introduction (scope narrative)
1. Contact Information (+ Principals/Affiliates, past-2-years affirmation)
2. Implementation of STIR/SHAKEN Framework (Option 1/2/3, named upstream)
3. Robocall Monitoring and Mitigation
3.5 Know Your Customer (KYC) Procedures (Performed In-House)
4. Call Analytics and Upstream Provider Procedures
5. Compliance with FCC Requirements
6. Future Enhancements
7. Commitment to Correct Deficiencies
Conclusion
Perjury declaration + signature block
Usage:
from scripts.document_gen.templates.rmd_exhibit_a_generator import (
generate_exhibit_a,
)
path = generate_exhibit_a(
entity_name="Falcon Broadband LLC",
entity_abbr="FBL",
frn="0027160886",
address="123 Example St, City, ST 00000",
contact_name="Jane Doe",
contact_title="President",
contact_email="jane@falconbroadband.com",
contact_phone="555-123-4567",
principals=["Jane Doe"],
carrier_role="ucaas",
upstream_provider_name="VoIP Innovations",
rmd_option="option2", # "option1" | "option2" | "option3"
scope_narrative="small UCaaS provider serving retail end-users",
output_path="/tmp/rmd_plan.docx",
)
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, Optional
LOG = logging.getLogger("document_gen.rmd_exhibit_a")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
except ImportError:
LOG.warning("python-docx not installed — RMD plan generation unavailable")
Document = None # type: ignore[assignment, misc]
NAVY_BLUE = RGBColor(0x1A, 0x27, 0x44) if Document else None
HEADING_SIZE = Pt(12)
BODY_SIZE = Pt(10)
PARA_SPACING_AFTER = Pt(6)
# ── RMD option labels per FCC 2025 R&O ────────────────────────────────────
# Option 1: complete STIR/SHAKEN implementation
# Option 2: partial STIR/SHAKEN — IP portions only, relying on upstream
# Option 3: robocall-mitigation-only (no STIR/SHAKEN signing at all)
RMD_OPTION_LABELS = {
"option1": "Option 1 — Complete STIR/SHAKEN Implementation",
"option2": "Option 2 — Partial STIR/SHAKEN Implementation",
"option3": "Option 3 — Robocall Mitigation Only (no STIR/SHAKEN signing)",
}
# ── Role-specific STIR/SHAKEN language ────────────────────────────────────
def _stir_shaken_paragraph(
*,
entity_name: str,
entity_abbr: str,
rmd_option: str,
upstream_provider_name: str,
carrier_role: str,
ocn: str,
) -> list[str]:
"""Return a list of paragraphs describing STIR/SHAKEN implementation."""
option = rmd_option.lower()
upstream = upstream_provider_name or "its underlying carrier"
paras: list[str] = []
if option == "option1":
paras.append(
f"{entity_name} complies with the STIR/SHAKEN call authentication "
f"framework and signs all outbound calls originated from its "
f"network using its own STI certificate. {entity_abbr} certifies "
f"complete STIR/SHAKEN implementation (Option 1 in the RMD)."
)
if ocn:
paras.append(
f"All calls are attested at level A, B, or C as appropriate "
f"using {entity_abbr}'s OCN {ocn} under its STI-CA-issued "
f"certificate."
)
elif option == "option2":
paras.append(
f"{entity_name} complies with FCC STIR/SHAKEN caller authentication "
f"requirements through its partnership with {upstream}. This "
f"partnership ensures that all outbound calls are attested under "
f"the STIR/SHAKEN framework; calls originating from the network "
f"are validated to verify caller identity and detect spoofed "
f"calls."
)
paras.append(
f"{entity_abbr} certifies partial STIR/SHAKEN implementation "
f"(Option 2 in the RMD) for IP portions of the network, relying "
f"on {upstream} for technical attestation/signing. {entity_abbr} "
f"makes all attestation-level decisions based on verified customer "
f"right-to-use of DIDs and provides this information to the "
f"upstream carrier for signing. {entity_abbr} does not maintain "
f"its own SPC token or certificate, as this is unnecessary and "
f"disproportionate for a small provider without wholesale, "
f"high-volume origination, or facilities-based IP origination "
f"infrastructure."
)
elif option == "option3":
paras.append(
f"{entity_name} certifies no STIR/SHAKEN signing implementation "
f"(Option 3 in the RMD). {entity_abbr} does not originate "
f"outbound calls that require its own STIR/SHAKEN signing and "
f"does not maintain an SPC token or certificate. Inbound call "
f"authentication is verified using STIR/SHAKEN attestation "
f"results provided by upstream carriers."
)
else:
paras.append(
f"{entity_name} complies with FCC STIR/SHAKEN requirements in "
f"accordance with its filing option on the Robocall Mitigation "
f"Database."
)
paras.append(
f"{entity_abbr} confirms that no previous certification has been "
f"removed by Commission action."
)
return paras
# ── Role-specific scope language ──────────────────────────────────────────
_ROLE_SCOPE_DEFAULTS = {
"ucaas": "small UCaaS provider serving end-users",
"facilities": "facilities-based voice service provider serving end-users",
"reseller": "voice service reseller",
"wholesale_domestic": "domestic wholesale voice provider",
"gateway": "international gateway provider",
"international_only": "carrier handling exclusively international voice traffic",
}
def _scope_paragraph(
*,
entity_name: str,
entity_abbr: str,
carrier_role: str,
scope_narrative: str,
is_wholesale: bool,
is_gateway: bool,
foreign_traffic: bool,
) -> str:
default_role = _ROLE_SCOPE_DEFAULTS.get(carrier_role, "voice service provider")
narrative = scope_narrative or default_role
parts = [
f"{entity_name} (\"{entity_abbr}\"), a {narrative}, is committed to "
f"mitigating unlawful robocalls and complying with Federal "
f"Communications Commission (FCC) regulations."
]
if not is_wholesale and carrier_role not in ("wholesale_domestic", "gateway"):
parts.append(
f"{entity_abbr} does not provide wholesale services, SIP trunking, "
f"origination for resellers, or act as an intermediate/gateway "
f"provider in any call path."
)
if not foreign_traffic:
parts.append(
f"{entity_abbr} does not accept foreign-originated traffic and "
f"operates solely with domestic U.S. NANP resources."
)
parts.append(
f"As a small provider without its own Class 4 switch or outbound "
f"origination platform, {entity_abbr} relies on trusted underlying "
f"carriers for DID origination, call termination, and STIR/SHAKEN "
f"attestation/signing where applicable."
)
parts.append(
f"This Robocall Mitigation Plan outlines {entity_abbr}'s measures to "
f"detect, prevent, and mitigate unlawful robocalls in compliance "
f"with FCC regulations, including 47 CFR \u00a7 64.6305 and the updated "
f"requirements from the 2025 Robocall Mitigation Database Report "
f"and Order (effective February 5, 2026, with first annual "
f"recertification due March 2, 2026 because March 1, 2026 falls on "
f"a Sunday)."
)
return "\n\n".join(parts)
# ── Doc helpers ───────────────────────────────────────────────────────────
def _add_heading(doc, text: str, level: int = 1) -> None:
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(12 if level == 1 else 8)
p.paragraph_format.space_after = PARA_SPACING_AFTER
run = p.add_run(text)
run.bold = True
run.font.size = HEADING_SIZE if level == 1 else Pt(11)
run.font.color.rgb = NAVY_BLUE
def _add_body(doc, text: str, bold: bool = False) -> None:
for chunk in text.split("\n\n"):
chunk = chunk.strip()
if not chunk:
continue
p = doc.add_paragraph()
p.paragraph_format.space_after = PARA_SPACING_AFTER
run = p.add_run(chunk)
run.font.size = BODY_SIZE
run.bold = bold
def _add_bullets(doc, items: list[str], *, indent: float = 0.25) -> None:
for item in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(indent)
p.paragraph_format.space_after = Pt(3)
p.clear()
run = p.add_run(item)
run.font.size = BODY_SIZE
def _add_page_number_footer(doc) -> None:
for section in doc.sections:
footer = section.footer
footer.is_linked_to_previous = False
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
for field_char in ("begin",):
fc = OxmlElement("w:fldChar")
fc.set(qn("w:fldCharType"), field_char)
run._element.append(fc)
r2 = p.add_run()
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = " PAGE "
r2._element.append(instr)
r3 = p.add_run()
fc2 = OxmlElement("w:fldChar")
fc2.set(qn("w:fldCharType"), "end")
r3._element.append(fc2)
# ── Main generator ───────────────────────────────────────────────────────
def generate_exhibit_a(
# Identity
entity_name: str,
entity_abbr: str = "",
frn: str = "",
ocn: str = "",
address: str = "",
# Contact
contact_name: str = "",
contact_title: str = "",
contact_email: str = "",
contact_phone: str = "",
# Principals / affiliates (list of names or short descriptions)
principals: list[str] | None = None,
affiliates: list[str] | None = None,
# Classification
carrier_role: str = "facilities",
carrier_metadata: dict | None = None,
upstream_provider_name: str = "",
is_wholesale: bool = False,
is_gateway: bool = False,
foreign_traffic: bool = False,
# RMD filing option — "option1", "option2", "option3"
rmd_option: str = "option2",
# Operational narrative
scope_narrative: str = "",
high_risk_alert_sla_hours: int = 4,
# Analytics vendors / systems (optional)
analytics_systems: list[str] | None = None,
third_party_vendors: list[str] | None = None,
# Signature
signer_name: str = "",
signer_title: str = "",
# Legacy LLM hook kept for backwards compat — ignored (no-op)
llm_generate: Callable[[str, str], Awaitable[str]] | None = None,
# Output
output_path: str = "/tmp/rmd_plan.docx",
) -> Optional[str]:
"""Generate the PW canonical Robocall Mitigation Plan as a DOCX file."""
if Document is None:
LOG.error("python-docx not installed")
return None
entity_abbr = entity_abbr or _derive_abbr(entity_name)
principals = principals or []
affiliates = affiliates or []
signer_name = signer_name or contact_name
signer_title = signer_title or contact_title
doc = Document()
for section in doc.sections:
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
_add_page_number_footer(doc)
today = datetime.now().strftime("%B %d, %Y")
# Title + "Updated as of"
title_p = doc.add_paragraph()
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_run = title_p.add_run(f"Robocall Mitigation Plan for {entity_name}")
title_run.font.size = Pt(14)
title_run.bold = True
title_run.font.color.rgb = NAVY_BLUE
title_p.paragraph_format.space_after = Pt(2)
subtitle_p = doc.add_paragraph()
subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub_run = subtitle_p.add_run(f"Updated as of {today}")
sub_run.font.size = Pt(10)
sub_run.italic = True
subtitle_p.paragraph_format.space_after = Pt(12)
# ── Introduction ──
_add_heading(doc, "Introduction")
_add_body(doc, _scope_paragraph(
entity_name=entity_name, entity_abbr=entity_abbr,
carrier_role=carrier_role, scope_narrative=scope_narrative,
is_wholesale=is_wholesale, is_gateway=is_gateway,
foreign_traffic=foreign_traffic,
))
# ── 1. Contact Information ──
_add_heading(doc, "1. Contact Information")
_add_body(doc, entity_name)
if address:
_add_body(doc, f"Address: {address}")
if contact_name:
title_suffix = f", {contact_title}" if contact_title else ""
_add_body(doc, f"Primary Contact: {contact_name}{title_suffix}")
if contact_email:
_add_body(doc, f"Email Address: {contact_email}")
if contact_phone:
_add_body(doc, f"Phone: {contact_phone}")
if frn:
_add_body(doc, f"FRN: {frn}")
if ocn:
_add_body(doc, f"OCN: {ocn}")
else:
_add_body(doc, (
f"{entity_abbr} does not possess an Operating Company Number "
f"(OCN). Per FCC guidance, no OCN is required for a small "
f"retail provider without local exchange carrier status; "
f"\"No\" is selected on the RMD form."
))
_add_body(doc, "Principals, Affiliates, Subsidiaries, and Parent Companies:", bold=True)
_add_bullets(doc, principals or [f"{contact_name or entity_name} (sole principal)"])
if affiliates:
_add_body(doc, "Affiliates:")
_add_bullets(doc, affiliates)
_add_body(doc, (
f"{entity_abbr} affirms that neither it nor any affiliated entity "
f"has been subject to FCC or law enforcement action related to "
f"illegal robocalling, spoofing, or RMD deficiencies in the past "
f"two years."
))
# ── 2. STIR/SHAKEN ──
_add_heading(doc, "2. Implementation of STIR/SHAKEN Framework")
stir_paras = _stir_shaken_paragraph(
entity_name=entity_name, entity_abbr=entity_abbr,
rmd_option=rmd_option,
upstream_provider_name=upstream_provider_name,
carrier_role=carrier_role,
ocn=ocn,
)
for para in stir_paras:
_add_body(doc, para)
_add_body(doc, f"RMD filing option selected: {RMD_OPTION_LABELS.get(rmd_option.lower(), rmd_option)}", bold=True)
# ── 3. Robocall Monitoring and Mitigation ──
_add_heading(doc, "3. Robocall Monitoring and Mitigation")
_add_body(doc, (
f"{entity_abbr} actively works to prevent illegal robocalls from "
f"originating or transiting through its network. The program applies "
f"to all voice traffic that {entity_abbr} originates, transits, or "
f"terminates, and includes the following elements:"
))
_add_body(doc, "Traffic Monitoring:", bold=True)
_add_bullets(doc, [
"Monitoring call patterns for anomalies \u2014 high call volumes to specific destinations, short-duration calls, ASR/ACD deviations, call velocity, and snowshoeing.",
f"Receiving and reviewing all high-risk alerts within {high_risk_alert_sla_hours} hours and taking immediate action.",
"Investigating and addressing suspicious activity promptly.",
])
_add_body(doc, "Customer Vetting:", bold=True)
_add_bullets(doc, [
"Verifying the identity of new customers and assessing the legitimacy of their intended usage.",
"Ensuring customers agree to terms prohibiting illegal robocalling.",
"Immediately blocking any numbers identified on the FCC Do-Not-Originate (DNO) list via upstream enforcement.",
"Taking reasonable steps to prevent new and renewing customers from originating illegal robocalls.",
])
_add_body(doc, "Complaint Resolution:", bold=True)
_add_bullets(doc, [
"Providing a clear process for individuals to report suspected robocalls (including email to the support address above).",
"Investigating complaints and taking corrective actions, including termination of services for violators.",
])
# ── 3.5 KYC (In-House) ──
_add_heading(doc, "3.5. Know Your Customer (KYC) Procedures (Performed In-House)", level=2)
_add_body(doc, (
f"{entity_name} conducts its own internal Know Your Customer (KYC) "
f"process for all new customers and customer renewals to ensure "
f"that only legitimate entities and individuals are granted access "
f"to services capable of making outbound calls or using numbering "
f"resources."
))
_add_body(doc, "Collection and Verification of Customer Information. At account signup or upon any material change in service usage, we require and collect:", bold=True)
_add_bullets(doc, [
"Full legal name of the individual or entity",
"Physical business address (no P.O. boxes accepted for high-volume or toll-free services)",
"Business identification number (EIN, or equivalent tax ID for non-U.S. entities) or, for individuals, the last four digits of SSN or government-issued ID number",
"At least one valid government-issued photo ID for the account owner or authorized officer",
"Business website, or if none exists, a description of the legitimate business purpose for the service",
])
_add_body(doc, "Verification Steps Performed In-House. Staff manually verify the provided information by:", bold=True)
_add_bullets(doc, [
"Cross-referencing business name and EIN against public state business registry databases or IRS records where available",
"Confirming the provided physical address via USPS address validation tools and third-party data sources (e.g., Google Maps satellite/street view confirmation)",
"Verifying that the submitted photo ID matches the name and appears authentic",
"Conducting an open-source and web search for the customer and principals to identify any prior association with illegal robocalling, call spoofing, or inclusion on the Industry Traceback Group's known bad-actor list",
])
_add_body(doc, "Red-Flag Review and Enhanced Due Diligence. If any of the following risk indicators are present, we perform enhanced in-house due diligence before activating or continuing service:", bold=True)
_add_bullets(doc, [
"Customer is unwilling or unable to provide complete KYC information",
"Discrepancies between provided information and public records",
"Use of privacy-protected or anonymous registration services for domains/websites",
"Requested usage patterns inconsistent with stated business purpose",
"Prior complaints or traceback involvement linked to the customer or its principals",
])
_add_body(doc, (
"Acceptance of Terms Prohibiting Illegal Activity. All customers "
"must electronically acknowledge and agree to our Acceptable Use "
"Policy and Robocall Policy, which explicitly prohibit the "
"origination or facilitation of illegal robocalls, unlawful caller "
"ID spoofing, or any violation of the Telephone Consumer Protection "
"Act (TCPA) or Telemarketing Sales Rule (TSR)."
))
_add_body(doc, (
"Ongoing Monitoring and Re-Vetting. Existing customers are subject "
"to periodic re-vetting (at least annually for high-volume or "
"toll-free customers) and immediate re-review upon receipt of "
"complaints, traceback requests, or detected anomalous traffic "
"patterns."
))
# ── 4. Call Analytics and Upstream Provider Procedures ──
_add_heading(doc, "4. Call Analytics and Upstream Provider Procedures")
_add_body(doc, "Call Analytics:", bold=True)
if analytics_systems or third_party_vendors:
items: list[str] = []
if analytics_systems:
items.append("Analytics systems deployed: " + ", ".join(analytics_systems))
if third_party_vendors:
items.append("Third-party analytics vendors: " + ", ".join(third_party_vendors))
items.append("Systems analyze call patterns in real time to identify potential violations.")
_add_bullets(doc, items)
else:
_add_bullets(doc, [
f"{entity_abbr} utilizes the underlying carriers' real-time call analytics platforms, which monitor ASR, ACD, call velocity, short-duration patterns, and snowshoeing.",
f"{entity_abbr} receives and reviews all high-risk alerts within {high_risk_alert_sla_hours} hours and takes immediate action.",
"As a small provider without its own switching platform, independent analytics are unnecessary and disproportionate to risk.",
])
_add_body(doc, "Upstream Provider Procedures:", bold=True)
_add_bullets(doc, [
"Prior to contracting and annually thereafter, we verify each upstream provider's active RMD filing, STIR/SHAKEN status, and robocall mitigation plan via the RMD portal before routing any traffic.",
"Periodic reviews confirm ongoing compliance.",
])
# ── 5. Compliance with FCC Requirements ──
_add_heading(doc, "5. Compliance with FCC Requirements")
_add_body(doc, f"{entity_abbr} fully complies with FCC robocall mitigation rules:")
_add_bullets(doc, [
"Maintains an active filing in the FCC's Robocall Mitigation Database (RMD), including role (voice service provider serving end-users), STIR/SHAKEN status, and mitigation plan.",
"Annual Recertification: Recertifies RMD filing annually by March 1 each year (for 2026, by March 2 because March 1 is a Sunday; window opened February 1, 2026). Recertification involves logging into the RMD portal with multi-factor authentication (required effective February 5, 2026), verifying accuracy of all information, updating if needed, and submitting via the \"Recertify\" button.",
"Prompt Updates: Updates RMD and CORES registration within 10 business days of any material change (e.g., ownership, contacts, STIR/SHAKEN posture, upstream provider, trade names), per 47 CFR \u00a7 64.6305.",
"Responds fully to Industry Traceback Group (ITG) traceback requests within 24 hours.",
"Pays any required filing fees to the FCC.",
"Responds promptly to FCC deficiency notices, curing issues within specified timeframes to avoid RMD removal or traffic blocking.",
])
_add_body(doc, (
"Noncompliance risks under the 2025 RMD R&O include a base forfeiture "
"of $10,000 for false or inaccurate information and $1,000 per day "
"until cured for failure to update RMD information within 10 "
"business days of a material change."
))
# ── 6. Future Enhancements ──
_add_heading(doc, "6. Future Enhancements")
_add_body(doc, f"{entity_abbr} commits to ongoing improvement:")
_add_bullets(doc, [
"Monitoring upstream carrier enhancements for better analytics/blocking.",
"Educating customers on robocall awareness and reporting.",
"Adapting to emerging threats (e.g., AI voice cloning) via upstream partnerships and FCC guidance.",
"Strengthening partnerships with industry organizations and regulators to stay ahead of emerging robocall trends.",
])
# ── 7. Commitment to Correct Deficiencies ──
_add_heading(doc, "7. Commitment to Correct Deficiencies")
_add_body(doc, (
f"{entity_abbr} will respond promptly to any FCC notice of deficiency "
f"in its RMD certification. This includes:"
))
_add_bullets(doc, [
"Updating RMD certifications and robocall mitigation plans to cure identified deficiencies.",
"Providing detailed explanations to the FCC regarding corrective actions taken.",
"Ensuring compliance within the specified timeframe to avoid removal from the RMD.",
])
# ── Conclusion ──
_add_heading(doc, "Conclusion")
_add_body(doc, (
f"{entity_name} is dedicated to protecting its customers and the "
f"public from the harm caused by illegal robocalls. Through "
f"reliable upstream partnerships, customer-focused controls, and "
f"full adherence to updated FCC requirements (including 2026 annual "
f"recertification by March 2, 2026), {entity_abbr} provides secure, "
f"compliant voice services."
))
# ── Perjury declaration + signature ──
_add_heading(doc, " ", level=2) # spacer
_add_body(doc, (
"I declare under penalty of perjury under the laws of the United "
"States of America that to the best of my knowledge the foregoing "
"is true and correct."
))
_add_body(doc, "")
sig = doc.add_paragraph()
sig.add_run("_" * 45).font.size = BODY_SIZE
_add_body(doc, signer_name or "[Authorized Signer]", bold=True)
_add_body(doc, signer_title or "[Title]")
_add_body(doc, entity_name)
_add_body(doc, f"Date: {today}")
# Save
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output))
LOG.info("RMD Plan (Exhibit A) generated: %s", output)
return str(output)
# ── Helpers ───────────────────────────────────────────────────────────────
def _derive_abbr(entity_name: str) -> str:
"""Build a 2-4 letter abbreviation from the legal name."""
words = [w for w in entity_name.replace(",", "").split() if w.lower() not in (
"inc", "inc.", "llc", "llc.", "corporation", "corp", "corp.", "co", "co.",
"ltd", "ltd.", "company", "the", "a", "of",
)]
if not words:
return entity_name[:3].upper()
letters = "".join(w[0].upper() for w in words[:3])
return letters if len(letters) >= 2 else entity_name[:3].upper()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,303 @@
"""
Traffic Study Page Stamper for FCC Form 499-A filings.
2026 Form 499-A Section IV.C.5.h requires carriers that submit a
traffic study (in lieu of electing a safe-harbor allocation) to stamp
every page of the study with a one-line header identifying the Filer
ID, Company Name, and Affiliated Filers Name. USAC uses this header
to match the study back to the 499-A submission and to verify
consistency across affiliated filers.
Primary path
------------
Try to generate a text overlay via ``reportlab`` and merge it onto
each page of the source PDF with ``pypdf``. Each overlay PDF matches
the media-box size of its corresponding source page so that the
merge is geometrically correct.
Fallback
--------
If ``reportlab`` is not installed, attempt a best-effort stamping
using a pypdf-authored PageObject with a small content-stream
annotation. If that also fails, copy the source PDF to the output
path unchanged, log a warning, and return the output path (the 499-A
submission plan requires the filing to proceed regardless).
Usage
-----
from scripts.document_gen.traffic_study_stamper import stamp_pages
out = stamp_pages(
pdf_path="/data/traffic_study.pdf",
output_path="/data/traffic_study.stamped.pdf",
filer_id="812345",
company_name="Acme Telco LLC",
affiliated_filers_name="Acme Holdings",
)
"""
from __future__ import annotations
import io
import logging
import shutil
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("document_gen.traffic_study_stamper")
# ── PDF core (required) ─────────────────────────────────────────────
try:
from pypdf import PdfReader, PdfWriter, PageObject
from pypdf.generic import (
ContentStream,
NameObject,
NumberObject,
TextStringObject,
)
_HAS_PYPDF = True
except ImportError:
LOG.warning("pypdf not installed — traffic study stamping unavailable")
PdfReader = None # type: ignore[assignment,misc]
PdfWriter = None # type: ignore[assignment,misc]
PageObject = None # type: ignore[assignment,misc]
_HAS_PYPDF = False
# ── Reportlab (preferred overlay path; optional) ────────────────────
try:
from reportlab.pdfgen import canvas as _rl_canvas # type: ignore
_HAS_REPORTLAB = True
except ImportError:
_rl_canvas = None # type: ignore[assignment]
_HAS_REPORTLAB = False
STAMP_FONT_PT = 8
STAMP_MARGIN_Y_PT = 20 # distance from top of page
STAMP_MARGIN_X_PT = 36 # 0.5 inch from left
def _format_stamp(filer_id: str, company_name: str, affiliated_filers_name: str) -> str:
"""Build the stamp-text one-liner per Form 499-A Section IV.C.5.h."""
return (
f"Filer ID {filer_id or '\u2014'} | "
f"{company_name or '\u2014'} | "
f"Affiliated Filers: {affiliated_filers_name or '\u2014'}"
)
def _overlay_reportlab(
stamp_text: str, width: float, height: float
) -> Optional[bytes]:
"""Build a one-page overlay PDF (bytes) sized (width, height) with the
stamp drawn at the top-left. Returns None if reportlab can't be used."""
if not _HAS_REPORTLAB or _rl_canvas is None:
return None
try:
buf = io.BytesIO()
c = _rl_canvas.Canvas(buf, pagesize=(width, height))
c.setFont("Helvetica", STAMP_FONT_PT)
# y measured from bottom of page; header sits near top
y = height - STAMP_MARGIN_Y_PT
c.drawString(STAMP_MARGIN_X_PT, y, stamp_text)
c.showPage()
c.save()
return buf.getvalue()
except Exception as exc: # pragma: no cover
LOG.warning("reportlab overlay build failed: %s", exc)
return None
def _apply_overlay_via_pypdf(
page: "PageObject", # type: ignore[name-defined]
overlay_pdf_bytes: bytes,
) -> None:
"""Merge a single-page overlay PDF onto the given source page."""
from pypdf import PdfReader as _Reader
overlay_reader = _Reader(io.BytesIO(overlay_pdf_bytes))
if not overlay_reader.pages:
return
page.merge_page(overlay_reader.pages[0])
def _stamp_via_content_stream(
page: "PageObject", # type: ignore[name-defined]
stamp_text: str,
page_height: float,
) -> bool:
"""
Fallback stamping when reportlab is unavailable.
Appends a minimal PDF content stream to draw ``stamp_text`` at the
top of ``page``. Returns True on success, False on any exception.
"""
try:
# Escape parentheses / backslashes per PDF string encoding.
safe = (
stamp_text.replace("\\", "\\\\")
.replace("(", "\\(")
.replace(")", "\\)")
)
y = page_height - STAMP_MARGIN_Y_PT
# Use Helvetica (F1) at STAMP_FONT_PT. We add an /F1 resource
# reference if missing.
stream = (
f"q BT /F1 {STAMP_FONT_PT} Tf "
f"{STAMP_MARGIN_X_PT} {y} Td ({safe}) Tj ET Q"
).encode("latin-1", errors="replace")
existing = page.get_contents()
from pypdf.generic import ByteStringObject, ArrayObject
new_cs = ContentStream(None, None)
new_cs.set_data(stream)
# Ensure /Font /F1 exists in the page resources.
from pypdf.generic import DictionaryObject, IndirectObject
resources = page.get("/Resources")
if isinstance(resources, IndirectObject):
resources = resources.get_object()
if resources is None:
resources = DictionaryObject()
page[NameObject("/Resources")] = resources
fonts = resources.get("/Font")
if isinstance(fonts, IndirectObject):
fonts = fonts.get_object()
if fonts is None:
fonts = DictionaryObject()
resources[NameObject("/Font")] = fonts
if "/F1" not in fonts:
helv = DictionaryObject(
{
NameObject("/Type"): NameObject("/Font"),
NameObject("/Subtype"): NameObject("/Type1"),
NameObject("/BaseFont"): NameObject("/Helvetica"),
}
)
fonts[NameObject("/F1")] = helv
# Append the new content stream. If existing /Contents is an
# array, append. Otherwise, wrap both into an array.
if existing is None:
page[NameObject("/Contents")] = new_cs
else:
# merge_page would normally handle this; we emulate the
# simplest case by concatenating streams.
try:
combined = ContentStream(None, None)
combined.set_data(existing.get_data() + b"\n" + stream)
page[NameObject("/Contents")] = combined
except Exception:
# Last-ditch: prepend via an array.
page[NameObject("/Contents")] = ArrayObject([existing, new_cs])
return True
except Exception as exc:
LOG.warning("pypdf content-stream stamping failed: %s", exc)
return False
def stamp_pages(
pdf_path: str,
output_path: str,
filer_id: str,
company_name: str,
affiliated_filers_name: str = "\u2014",
) -> str:
"""
Stamp every page of ``pdf_path`` with a one-line header containing
the Filer ID, Company Name, and Affiliated Filers Name. Write the
result to ``output_path``. Return ``output_path``.
This function is best-effort by design. The Form 499-A filing plan
requires that the submission proceed even when fancy stamping fails
(e.g., in a constrained environment missing ``reportlab``). On any
unrecoverable error the source PDF is copied verbatim to the
output path and a warning is logged.
"""
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
src = Path(pdf_path)
if not src.exists():
raise FileNotFoundError(f"source PDF not found: {pdf_path}")
stamp_text = _format_stamp(filer_id, company_name, affiliated_filers_name)
if not _HAS_PYPDF:
LOG.warning(
"pypdf unavailable — copying source PDF unchanged to %s", out
)
shutil.copyfile(src, out)
return str(out)
try:
reader = PdfReader(str(src))
writer = PdfWriter()
overlay_mode = "reportlab" if _HAS_REPORTLAB else "content_stream"
for page in reader.pages:
mb = page.mediabox
width = float(mb.width)
height = float(mb.height)
stamped = False
if overlay_mode == "reportlab":
overlay_bytes = _overlay_reportlab(stamp_text, width, height)
if overlay_bytes:
try:
_apply_overlay_via_pypdf(page, overlay_bytes)
stamped = True
except Exception as exc:
LOG.warning(
"overlay merge failed on page; "
"falling back to content stream: %s", exc
)
if not stamped:
_stamp_via_content_stream(page, stamp_text, height)
writer.add_page(page)
with out.open("wb") as fh:
writer.write(fh)
if overlay_mode != "reportlab":
LOG.warning(
"reportlab not available — used pypdf content-stream fallback "
"to stamp %s (filer=%s).", out, filer_id
)
else:
LOG.info(
"Traffic study stamped via reportlab overlay: %s (filer=%s)",
out, filer_id,
)
return str(out)
except Exception as exc:
LOG.warning(
"traffic-study stamping failed (%s); copying source unchanged "
"to preserve filing timeline.", exc
)
try:
shutil.copyfile(src, out)
except Exception as exc2: # pragma: no cover
LOG.error("fallback copy also failed: %s", exc2)
raise
return str(out)
if __name__ == "__main__": # pragma: no cover
import argparse
logging.basicConfig(level=logging.INFO)
ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
ap.add_argument("source_pdf")
ap.add_argument("output_pdf")
ap.add_argument("--filer-id", required=True)
ap.add_argument("--company-name", required=True)
ap.add_argument("--affiliated-filers-name", default="\u2014")
args = ap.parse_args()
p = stamp_pages(
pdf_path=args.source_pdf,
output_path=args.output_pdf,
filer_id=args.filer_id,
company_name=args.company_name,
affiliated_filers_name=args.affiliated_filers_name,
)
print(p)

View file

@ -0,0 +1 @@
# Performance West — 50-State Business Formation Automation

388
scripts/formation/base.py Normal file
View file

@ -0,0 +1,388 @@
"""
Base class for state Secretary of State portal automation.
Each state adapter inherits from StatePortal and implements:
- search_name() -> Check business name availability
- file_llc() -> File LLC Articles of Organization
- file_corporation() -> File Articles of Incorporation
- check_status() -> Check filing status
- download_docs() -> Download filed documents
All state adapters use Playwright for browser automation.
The base class provides shared utilities: screenshot capture, retry logic,
CAPTCHA detection, error reporting, and state-specific delay injection
(to appear human-paced).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import random
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional
from playwright.async_api import Browser, BrowserContext, Page
# Undetected Playwright launcher (patchright + stealth fallback). Shared with
# the FCC / USAC / BDC compliance filing handlers.
from scripts.workers.services.telecom.undetected_browser import (
launch_context as _undetected_launch_context,
)
# Keep async_playwright import available for backwards compat (tests may patch
# this symbol). When the helper is in use, prefer the shared launcher.
try:
from patchright.async_api import async_playwright # type: ignore
except ImportError:
from playwright.async_api import async_playwright # type: ignore
LOG = logging.getLogger("formation")
SCREENSHOTS_DIR = Path(os.getenv("SCREENSHOTS_DIR", "/tmp/formation-screenshots"))
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
class EntityType(str, Enum):
LLC = "llc"
CORPORATION = "corporation"
S_CORP = "s_corp" # Corp + IRS 2553 election
class FilingStatus(str, Enum):
PENDING = "pending"
NAME_AVAILABLE = "name_available"
NAME_UNAVAILABLE = "name_unavailable"
SUBMITTED = "submitted"
PROCESSING = "processing"
FILED = "filed"
REJECTED = "rejected"
ERROR = "error"
@dataclass
class NameSearchResult:
available: bool
exact_match: bool = False
similar_names: list[str] = field(default_factory=list)
state_code: str = ""
searched_name: str = ""
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
raw_response: str = ""
@dataclass
class Member:
name: str
address: str
city: str
state: str
zip_code: str
title: str = "Member" # Member, Manager, Organizer, Director, etc.
ownership_pct: float = 0.0
is_organizer: bool = False # Signs the formation docs
@dataclass
class FormationOrder:
"""All information needed to file a business entity in any state."""
order_id: str
state_code: str
entity_type: EntityType
entity_name: str
entity_name_alt: str = "" # Backup name if primary unavailable
# Management
management_type: str = "member_managed" # member_managed or manager_managed (LLC)
purpose: str = "Any lawful business activity"
# People
members: list[Member] = field(default_factory=list)
registered_agent_name: str = "Northwest Registered Agent"
registered_agent_address: str = "" # Populated per-state from NW RA
# Addresses
principal_address: str = ""
principal_city: str = ""
principal_state: str = ""
principal_zip: str = ""
mailing_address: str = ""
mailing_city: str = ""
mailing_state: str = ""
mailing_zip: str = ""
# Corp-specific
shares_authorized: int = 10000 # Default for corp formation (BC flat fee, no per-share cost)
par_value: float = 0.0 # 0 = no par value
fiscal_year_end: str = "12/31"
# Regulatory contact (for CRTC letter — populated from provisioned Canadian identity)
regulatory_contact_name: str = "Regulatory Director"
regulatory_contact_email: str = "" # regulatory@{.ca domain}
regulatory_contact_phone: str = "" # Canadian DID from Flowroute
# Options
expedited: bool = False
effective_date: str = "" # Empty = immediate, else future date
# Payment (Relay virtual debit card — loaded from ERPNext Sensitive ID at runtime)
payment_card_number: str = "" # Populated by worker before filing
payment_card_exp: str = "" # MM/YY
payment_card_cvv: str = ""
payment_card_name: str = "Performance West Inc"
payment_card_zip: str = "82001" # Cheyenne, WY billing zip
# Results (populated during filing)
status: FilingStatus = FilingStatus.PENDING
state_filing_number: str = ""
filed_at: str = ""
confirmation_number: str = ""
documents: list[str] = field(default_factory=list) # File paths
error_message: str = ""
@dataclass
class FilingResult:
success: bool
status: FilingStatus
state_code: str
entity_name: str
filing_number: str = ""
confirmation_number: str = ""
error_message: str = ""
screenshot_path: str = ""
documents: list[str] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
def to_dict(self) -> dict:
return asdict(self)
class StatePortal(ABC):
"""Base class for all state SOS portal automations."""
STATE_CODE: str = ""
STATE_NAME: str = ""
PORTAL_NAME: str = ""
PORTAL_URL: str = ""
SUPPORTS_LLC: bool = True
SUPPORTS_CORP: bool = True
SUPPORTS_ONLINE_FILING: bool = True
SUPPORTS_NAME_SEARCH: bool = True
# NW Registered Agent address for this state (populated by subclass)
NWRA_ADDRESS: str = ""
NWRA_CITY: str = ""
NWRA_STATE: str = ""
NWRA_ZIP: str = ""
def __init__(self):
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None
self.log = logging.getLogger(f"formation.{self.STATE_CODE}")
async def start_browser(self, headless: bool = True) -> Page:
"""Launch browser with undetected/stealth settings.
Uses the shared patchright-based launcher in
``scripts/workers/services/telecom/undetected_browser.py`` so that
state SoS portals and FCC/USAC filing handlers share one stealth
implementation.
"""
pw = await async_playwright().start()
self.browser, self.context = await _undetected_launch_context(
pw,
headless=headless,
timezone_id="America/Denver",
)
self.page = await self.context.new_page()
return self.page
async def close_browser(self):
"""Shut down browser."""
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
async def screenshot(self, label: str) -> str:
"""Capture screenshot for debugging/audit trail."""
if not self.page:
return ""
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
path = SCREENSHOTS_DIR / f"{self.STATE_CODE}_{label}_{ts}.png"
await self.page.screenshot(path=str(path), full_page=True)
self.log.info("Screenshot saved: %s", path)
return str(path)
async def human_delay(self, min_s: float = 1.0, max_s: float = 3.0):
"""Random delay to appear human."""
delay = random.uniform(min_s, max_s)
await asyncio.sleep(delay)
async def type_slowly(self, selector: str, text: str, delay_ms: int = 50):
"""Type text character by character with random delays."""
if not self.page:
return
await self.page.click(selector)
for char in text:
await self.page.type(selector, char, delay=delay_ms + random.randint(0, 30))
async def safe_click(self, selector: str, timeout: int = 10000):
"""Click an element with wait and error handling."""
if not self.page:
return
await self.page.wait_for_selector(selector, timeout=timeout)
await self.human_delay(0.3, 0.8)
await self.page.click(selector)
async def detect_captcha(self) -> bool:
"""Check if a CAPTCHA is present on the page."""
if not self.page:
return False
captcha_selectors = [
"iframe[src*='recaptcha']",
"iframe[src*='hcaptcha']",
".g-recaptcha",
".h-captcha",
"#captcha",
"[class*='captcha']",
"iframe[src*='challenge']",
]
for sel in captcha_selectors:
try:
el = await self.page.query_selector(sel)
if el:
self.log.warning("CAPTCHA detected: %s", sel)
return True
except Exception:
pass
return False
# --- Abstract methods — each state implements these ---
@abstractmethod
async def search_name(self, name: str) -> NameSearchResult:
"""Search for business name availability in this state."""
...
@abstractmethod
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC Articles of Organization."""
...
@abstractmethod
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Articles of Incorporation."""
...
async def file_entity(self, order: FormationOrder) -> FilingResult:
"""Route to correct filing method based on entity type."""
if order.entity_type in (EntityType.LLC,):
return await self.file_llc(order)
elif order.entity_type in (EntityType.CORPORATION, EntityType.S_CORP):
return await self.file_corporation(order)
else:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=f"Unsupported entity type: {order.entity_type}",
)
async def check_status(self, filing_number: str) -> FilingStatus:
"""Check the status of a previously submitted filing."""
self.log.warning("check_status not implemented for %s", self.STATE_CODE)
return FilingStatus.PENDING
async def enter_payment(
self,
order: FormationOrder,
selectors: dict[str, str],
) -> bool:
"""Enter Relay virtual debit card payment on a state portal payment form.
Common payment form selectors (vary by state, passed from config):
card_number_field, card_exp_field, card_cvv_field,
card_name_field, card_zip_field, submit_payment_btn
Args:
order: FormationOrder with payment card details populated
selectors: Dict of CSS selectors for the payment form fields
Returns:
True if payment fields were filled and submitted successfully.
"""
if not self.page:
self.log.error("No browser page open for payment")
return False
if not order.payment_card_number:
self.log.error("No payment card number on order — card not loaded from ERPNext")
return False
await self.screenshot("payment_before")
self.log.info("Entering payment for %s ($%.2f)",
order.entity_name,
order.status) # Amount would come from state fee
try:
# Card number
if selectors.get("card_number_field"):
await self.type_slowly(selectors["card_number_field"], order.payment_card_number, delay_ms=40)
await self.human_delay(0.3, 0.6)
# Expiration (some states split into month/year, some have one field)
if selectors.get("card_exp_field"):
await self.type_slowly(selectors["card_exp_field"], order.payment_card_exp, delay_ms=40)
await self.human_delay(0.2, 0.5)
elif selectors.get("card_exp_month_field") and selectors.get("card_exp_year_field"):
month, year = order.payment_card_exp.split("/")
await self.page.select_option(selectors["card_exp_month_field"], month.strip())
await self.page.select_option(selectors["card_exp_year_field"], year.strip())
await self.human_delay(0.2, 0.5)
# CVV
if selectors.get("card_cvv_field"):
await self.type_slowly(selectors["card_cvv_field"], order.payment_card_cvv, delay_ms=40)
await self.human_delay(0.2, 0.5)
# Name on card
if selectors.get("card_name_field"):
await self.type_slowly(selectors["card_name_field"], order.payment_card_name, delay_ms=30)
await self.human_delay(0.2, 0.5)
# Billing ZIP
if selectors.get("card_zip_field"):
await self.type_slowly(selectors["card_zip_field"], order.payment_card_zip, delay_ms=30)
await self.human_delay(0.2, 0.5)
await self.screenshot("payment_filled")
# Submit payment
if selectors.get("submit_payment_btn"):
await self.safe_click(selectors["submit_payment_btn"])
await self.page.wait_for_load_state("networkidle", timeout=30000)
await self.human_delay(2.0, 4.0) # Payment processing delay
await self.screenshot("payment_after")
self.log.info("Payment submitted for %s", order.entity_name)
return True
except Exception as e:
self.log.error("Payment entry failed: %s", e)
await self.screenshot("payment_error")
return False
async def download_docs(self, filing_number: str) -> list[str]:
"""Download filed documents. Returns list of file paths."""
self.log.warning("download_docs not implemented for %s", self.STATE_CODE)
return []

View file

@ -0,0 +1,338 @@
"""bulk_download.py — Download business entity data from state open data portals.
Supports:
- Socrata SODA API (CO, AK, CT, IL, IA, MI, NY, OR, PA, VT, WA)
- SFTP bulk download (FL)
- HTTP CSV bulk download (CA, TX)
Run: python3 scripts/formation/bulk_download.py [--state CO] [--all]
"""
import os
import sys
import json
import time
import logging
import argparse
import urllib.request
import urllib.parse
import csv
import io
from typing import Optional
from datetime import datetime, timezone
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import psycopg2
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger(__name__)
DB_URL = os.getenv("DATABASE_URL", "postgresql://pw:pw@localhost:5432/performancewest")
# ── State data source registry ────────────────────────────────────────────────
SOCRATA_STATES = {
# Verified working 2026-04-20
"CO": {"url": "https://data.colorado.gov/resource/4ykn-tg5h.json", "name_field": "entityname", "number_field": "entityid", "type_field": "entitytypecode", "status_field": "entitystatus", "date_field": "entityformdate", "formation_state_field": "jurisdictonofformation"},
"IA": {"url": "https://data.iowa.gov/resource/ykb6-ywnd.json", "name_field": "entity_name", "number_field": "entity_number", "type_field": "entity_type", "status_field": "entity_status", "date_field": "date_formed", "formation_state_field": "home_state"},
"CT": {"url": "https://data.ct.gov/resource/n7gp-d28j.json", "name_field": "name", "number_field": "accountnumber", "type_field": "type", "status_field": "status", "date_field": "date_registration", "formation_state_field": "state_of_formation"},
"OR": {"url": "https://data.oregon.gov/resource/tckn-sxa6.json", "name_field": "business_name", "number_field": "registry_number", "type_field": "entity_type", "status_field": "status", "date_field": "registry_date", "formation_state_field": "state_of_origin"},
# NY dataset is active entities only (no status field — all are implicitly ACTIVE)
# jurisdiction field contains formation state ("New York" for domestic, other state for foreign)
"NY": {"url": "https://data.ny.gov/resource/n9v6-gdp6.json", "name_field": "current_entity_name", "number_field": "dos_id", "type_field": "entity_type", "status_field": "", "date_field": "initial_dos_filing_date", "formation_state_field": "jurisdiction", "default_status": "ACTIVE"},
# Broken as of 2026-04-20 — dataset IDs need updating (portals reorganized)
#
# "WA": {"url": "https://data.wa.gov/resource/????.json", ...},
# "IL": {"url": "https://data.illinois.gov/resource/????.json", ...},
# "PA": {"url": "https://data.pa.gov/resource/????.json", ...},
# "MI": {"url": "https://data.michigan.gov/resource/????.json", ...},
# "AK": {"url": "https://data.alaska.gov/resource/????.json", ...},
# "VT": {"url": "https://data.vermont.gov/resource/????.json", ...},
}
# States with alternative bulk download sources (not Socrata)
# These have downloadable CSV/XLSX files from their SOS websites
DIRECT_DOWNLOAD_STATES = {
# "FL": Florida Sunbiz provides monthly SFTP dump
# "CA": California SOS provides daily CSV extract
# "TX": Texas Comptroller provides downloadable SOSDirect data
# "WY": Wyoming SOS provides CSV export via WyoBiz
# "NV": Nevada SilverFlume provides searchable API (not bulk)
# "DE": Delaware Division of Corporations — no bulk data (paid API only)
}
# For states without bulk data: use Playwright live search on demand
# (slower, ~3-5s per lookup, cached 24h in name_search_cache)
# All 52 state adapters support search_name() for on-demand lookups
# ── Socrata downloader ────────────────────────────────────────────────────────
def download_socrata(state_code: str, config: dict) -> list[dict]:
"""Download all entities from a Socrata SODA API endpoint."""
base_url = config["url"]
all_records = []
offset = 0
batch_size = 50000
while True:
url = f"{base_url}?$limit={batch_size}&$offset={offset}&$order=:id"
log.info(f" [{state_code}] Fetching offset={offset}...")
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
data = json.loads(r.read())
except Exception as e:
log.error(f" [{state_code}] Socrata error at offset {offset}: {e}")
break
if not data:
break
for record in data:
# Extract formation state (where entity was originally incorporated)
fs_field = config.get("formation_state_field", "")
raw_formation_state = str(record.get(fs_field, "")).strip().upper() if fs_field else ""
# Normalize to 2-letter code (some states return full name)
formation_state = _normalize_state_code(raw_formation_state) if raw_formation_state else None
raw_status = str(record.get(config["status_field"], "")).strip() if config.get("status_field") else ""
entity = {
"entity_name": str(record.get(config["name_field"], "")).strip().upper(),
"entity_number": str(record.get(config["number_field"], "")).strip(),
"entity_type": _normalize_type(str(record.get(config["type_field"], "")).strip()),
"status": _normalize_status(raw_status) if raw_status else config.get("default_status", "ACTIVE"),
"formation_date": _parse_date(record.get(config["date_field"])),
"formation_state": formation_state,
"jurisdiction": f"US_{state_code}",
"state": state_code,
"registered_agent": str(record.get("registered_agent", record.get("agent_name", ""))).strip() or None,
"principal_address": _build_address(record),
}
if entity["entity_name"] and entity["entity_number"]:
all_records.append(entity)
offset += batch_size
if len(data) < batch_size:
break
time.sleep(0.5) # Be respectful to the API
return all_records
_STATE_NAME_TO_CODE = {
"ALABAMA": "AL", "ALASKA": "AK", "ARIZONA": "AZ", "ARKANSAS": "AR",
"CALIFORNIA": "CA", "COLORADO": "CO", "CONNECTICUT": "CT", "DELAWARE": "DE",
"DISTRICT OF COLUMBIA": "DC", "FLORIDA": "FL", "GEORGIA": "GA", "HAWAII": "HI",
"IDAHO": "ID", "ILLINOIS": "IL", "INDIANA": "IN", "IOWA": "IA",
"KANSAS": "KS", "KENTUCKY": "KY", "LOUISIANA": "LA", "MAINE": "ME",
"MARYLAND": "MD", "MASSACHUSETTS": "MA", "MICHIGAN": "MI", "MINNESOTA": "MN",
"MISSISSIPPI": "MS", "MISSOURI": "MO", "MONTANA": "MT", "NEBRASKA": "NE",
"NEVADA": "NV", "NEW HAMPSHIRE": "NH", "NEW JERSEY": "NJ", "NEW MEXICO": "NM",
"NEW YORK": "NY", "NORTH CAROLINA": "NC", "NORTH DAKOTA": "ND", "OHIO": "OH",
"OKLAHOMA": "OK", "OREGON": "OR", "PENNSYLVANIA": "PA", "RHODE ISLAND": "RI",
"SOUTH CAROLINA": "SC", "SOUTH DAKOTA": "SD", "TENNESSEE": "TN", "TEXAS": "TX",
"UTAH": "UT", "VERMONT": "VT", "VIRGINIA": "VA", "WASHINGTON": "WA",
"WEST VIRGINIA": "WV", "WISCONSIN": "WI", "WYOMING": "WY",
}
def _normalize_state_code(raw: str) -> Optional[str]:
"""Convert full state name or abbreviation to 2-letter code."""
raw = raw.strip().upper()
if len(raw) == 2 and raw.isalpha():
return raw
return _STATE_NAME_TO_CODE.get(raw)
def _normalize_type(raw: str) -> str:
upper = raw.upper()
if "LLC" in upper or "LIMITED LIABILITY" in upper:
return "LLC"
if "CORP" in upper or "INC" in upper:
return "CORPORATION"
if "LP" in upper or "LIMITED PARTNERSHIP" in upper:
return "LP"
if "LLP" in upper:
return "LLP"
if "NONPROFIT" in upper or "NOT FOR PROFIT" in upper:
return "NONPROFIT"
return raw.upper()[:50] if raw else None
def _normalize_status(raw: str) -> str:
upper = raw.upper()
if "ACTIVE" in upper or "GOOD STANDING" in upper or "CURRENT" in upper:
return "ACTIVE"
if "DISSOLV" in upper or "CANCEL" in upper:
return "DISSOLVED"
if "SUSPEND" in upper or "REVOK" in upper:
return "SUSPENDED"
if "DELINQ" in upper or "DEFAULT" in upper:
return "DELINQUENT"
if "INACTIVE" in upper or "WITHDRAWN" in upper:
return "INACTIVE"
return raw.upper()[:30] if raw else None
def _parse_date(val) -> str | None:
if not val:
return None
s = str(val).strip()
# ISO format
if len(s) >= 10 and s[4] == "-":
return s[:10]
# Socrata floating timestamp: "2020-03-15T00:00:00.000"
if "T" in s:
return s[:10]
return None
def _build_address(record: dict) -> str | None:
parts = []
for key in ["principal_address", "address", "street_address", "mailing_address",
"principal_office_addr", "addr_line1"]:
if key in record and record[key]:
parts.append(str(record[key]).strip())
break
for key in ["principal_city", "city"]:
if key in record and record[key]:
parts.append(str(record[key]).strip())
break
for key in ["principal_state", "state_province"]:
if key in record and record[key]:
parts.append(str(record[key]).strip())
break
for key in ["principal_zip", "zip", "postal_code"]:
if key in record and record[key]:
parts.append(str(record[key]).strip())
break
return ", ".join(parts) if parts else None
# ── Database upsert ───────────────────────────────────────────────────────────
def upsert_entities(entities: list[dict], state_code: str) -> int:
"""UPSERT entities into entity_cache table. Returns count of upserted rows."""
if not entities:
return 0
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
count = 0
try:
# Deduplicate by (jurisdiction, entity_number) to avoid ON CONFLICT errors
seen_keys: set = set()
deduped: list = []
for e in entities:
key = (e["jurisdiction"], e["entity_number"])
if key not in seen_keys:
seen_keys.add(key)
deduped.append(e)
if len(deduped) < len(entities):
log.info(f" Deduped: {len(entities)}{len(deduped)} ({len(entities) - len(deduped)} duplicates removed)")
entities = deduped
for batch_start in range(0, len(entities), 500):
batch = entities[batch_start:batch_start + 500]
values = []
for e in batch:
values.append(cur.mogrify(
"(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'socrata')",
(
e["jurisdiction"], e["entity_name"], e["entity_number"],
e["entity_type"], e["status"], e["formation_date"],
None, # dissolution_date
e.get("registered_agent"),
e.get("principal_address"),
e["state"],
e.get("formation_state"),
)
).decode())
sql = f"""
INSERT INTO entity_cache
(jurisdiction, entity_name, entity_number, entity_type, status,
formation_date, dissolution_date, registered_agent, principal_address,
state, formation_state, source)
VALUES {",".join(values)}
ON CONFLICT (jurisdiction, entity_number) DO UPDATE SET
entity_name = EXCLUDED.entity_name,
entity_type = EXCLUDED.entity_type,
status = EXCLUDED.status,
formation_date = EXCLUDED.formation_date,
formation_state = COALESCE(EXCLUDED.formation_state, entity_cache.formation_state),
registered_agent = EXCLUDED.registered_agent,
principal_address = EXCLUDED.principal_address,
last_synced = NOW()
"""
cur.execute(sql)
count += len(batch)
conn.commit()
finally:
cur.close()
conn.close()
return count
# ── Main ──────────────────────────────────────────────────────────────────────
def download_state(state_code: str) -> int:
"""Download all entities for a single state. Returns count."""
state_code = state_code.upper()
if state_code in SOCRATA_STATES:
log.info(f"Downloading {state_code} via Socrata SODA API...")
entities = download_socrata(state_code, SOCRATA_STATES[state_code])
else:
log.warning(f"{state_code}: no bulk download source configured (Playwright-only)")
return 0
if entities:
count = upsert_entities(entities, state_code)
log.info(f" [{state_code}] Upserted {count} entities")
return count
else:
log.warning(f" [{state_code}] No entities downloaded")
return 0
def main():
parser = argparse.ArgumentParser(description="Bulk download business entities from state open data portals")
parser.add_argument("--state", type=str, help="Download a single state (2-letter code)")
parser.add_argument("--all", action="store_true", help="Download all configured states")
parser.add_argument("--list", action="store_true", help="List available states")
args = parser.parse_args()
if args.list:
print("Socrata SODA API states:")
for code in sorted(SOCRATA_STATES.keys()):
print(f" {code}: {SOCRATA_STATES[code]['url']}")
return
if args.state:
total = download_state(args.state)
log.info(f"Done: {total} entities for {args.state.upper()}")
elif args.all:
grand_total = 0
for code in sorted(SOCRATA_STATES.keys()):
total = download_state(code)
grand_total += total
time.sleep(2) # Pause between states
log.info(f"Done: {grand_total} total entities across {len(SOCRATA_STATES)} states")
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,444 @@
"""
document_delivery.py Email formation documents to customers.
Sends a professional HTML email with attached formation documents
(Articles of Organization, EIN letter, operating agreement, etc.)
and updates the order status to 'delivered'.
Environment variables:
DATABASE_URL PostgreSQL connection string
SMTP_HOST SMTP server hostname
SMTP_PORT SMTP server port (default: 587)
SMTP_USER SMTP username / from address
SMTP_PASS SMTP password
Usage:
python -m formation.document_delivery <order_id>
"""
from __future__ import annotations
import email.mime.application
import email.mime.multipart
import email.mime.text
import json
import logging
import mimetypes
import os
import smtplib
import sys
from datetime import datetime, timezone
from pathlib import Path
import psycopg2
import psycopg2.extras
from .states import STATES
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DATABASE_URL = os.environ.get("DATABASE_URL", "")
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASS = os.environ.get("SMTP_PASS", "")
FROM_NAME = "Performance West"
FROM_EMAIL = SMTP_USER or "formations@performancewest.net"
LOG = logging.getLogger("formation.delivery")
# ---------------------------------------------------------------------------
# Email template
# ---------------------------------------------------------------------------
EMAIL_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your Business Has Been Filed</title>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding: 40px 0;">
<tr><td align="center">
<!-- Header -->
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#1a1a2e; border-radius:8px 8px 0 0; padding:30px 40px;">
<tr><td>
<h1 style="color:#ffffff; margin:0; font-size:24px; font-weight:600;">Performance West</h1>
<p style="color:#a0a0c0; margin:5px 0 0; font-size:14px;">Business Formation Services</p>
</td></tr>
</table>
<!-- Body -->
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; padding:40px;">
<tr><td>
<p style="font-size:16px; color:#333;">Dear {customer_name},</p>
<p style="font-size:16px; color:#333;">
Great news your <strong>{entity_type}</strong> has been successfully
filed with the state of <strong>{state_name}</strong>.
</p>
<!-- Filing details box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4ff; border-radius:8px; padding:24px; margin:24px 0;">
<tr><td>
<table width="100%" cellpadding="4" cellspacing="0">
<tr>
<td style="font-size:14px; color:#666; width:180px;">Entity Name</td>
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{entity_name}</td>
</tr>
<tr>
<td style="font-size:14px; color:#666;">State</td>
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{state_name}</td>
</tr>
<tr>
<td style="font-size:14px; color:#666;">Filing Number</td>
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{filing_number}</td>
</tr>
<tr>
<td style="font-size:14px; color:#666;">Confirmation Number</td>
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{confirmation_number}</td>
</tr>
<tr>
<td style="font-size:14px; color:#666;">Filed Date</td>
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{filed_date}</td>
</tr>
</table>
</td></tr>
</table>
<p style="font-size:16px; color:#333;">
Your formation documents are attached to this email.
</p>
<!-- Next steps -->
<h2 style="font-size:18px; color:#1a1a2e; margin:32px 0 16px;">Recommended Next Steps</h2>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="padding:8px 0; font-size:15px; color:#333;">
<strong>1. Obtain an EIN</strong> Apply for an Employer Identification Number
from the IRS. This is required to open a business bank account and file taxes.
{ein_note}
</td>
</tr>
<tr>
<td style="padding:8px 0; font-size:15px; color:#333;">
<strong>2. Operating Agreement</strong> Prepare and sign an operating agreement
for your {entity_type}. This document outlines ownership, management structure,
and member responsibilities.
</td>
</tr>
<tr>
<td style="padding:8px 0; font-size:15px; color:#333;">
<strong>3. Open a Business Bank Account</strong> Keep personal and business
finances separate. You'll need your Articles of Organization, EIN, and
operating agreement.
</td>
</tr>
<tr>
<td style="padding:8px 0; font-size:15px; color:#333;">
<strong>4. Business Licenses &amp; Permits</strong> Check your local
city/county requirements for any additional licenses or permits.
</td>
</tr>
<tr>
<td style="padding:8px 0; font-size:15px; color:#333;">
<strong>5. Annual Reports</strong> Most states require an annual or biennial
report. We'll send you a reminder when yours is due.
</td>
</tr>
</table>
<hr style="border:none; border-top:1px solid #e0e0e0; margin:32px 0;" />
<p style="font-size:14px; color:#666;">
If you have any questions about your filing or need additional services,
don't hesitate to reach out.
</p>
</td></tr>
</table>
<!-- Footer -->
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:24px 40px;">
<tr><td>
<p style="font-size:13px; color:#999; margin:0;">
Performance West &middot; Business Formation &amp; Compliance Services
</p>
<p style="font-size:13px; color:#999; margin:4px 0 0;">
Email: formations@performancewest.net &middot; Phone: (307) 316-5620
</p>
<p style="font-size:12px; color:#bbb; margin:12px 0 0;">
This email and any attachments are intended solely for the named recipient.
If you received this in error, please delete it and notify the sender.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def _get_connection():
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is not set.")
return psycopg2.connect(DATABASE_URL)
def _fetch_order(conn, order_id: str) -> dict | None:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
row = cur.fetchone()
return dict(row) if row else None
def _mark_delivered(conn, order_id: str):
with conn.cursor() as cur:
cur.execute(
"""
UPDATE formation_orders
SET status = 'delivered',
delivered_at = NOW(),
updated_at = NOW()
WHERE order_id = %s
""",
(order_id,),
)
conn.commit()
# ---------------------------------------------------------------------------
# Email sending
# ---------------------------------------------------------------------------
def _build_email(
customer_email: str,
customer_name: str,
entity_name: str,
entity_type: str,
state_code: str,
filing_number: str,
confirmation_number: str,
filed_date: str,
documents: list[str],
ein: str = "",
) -> email.mime.multipart.MIMEMultipart:
"""Build the MIME email with HTML body and document attachments."""
state_name = STATES.get(state_code.upper(), {}).get("name", state_code)
# Entity type display name
type_display = {
"llc": "LLC",
"corporation": "Corporation",
"s_corp": "S Corporation",
}.get(entity_type.lower(), entity_type)
ein_note = ""
if ein:
ein_note = f"<br/><em>Your EIN ({ein}) has already been obtained and is included in your documents.</em>"
html_body = EMAIL_HTML_TEMPLATE.format(
customer_name=customer_name,
entity_type=type_display,
entity_name=entity_name,
state_name=state_name,
filing_number=filing_number or "Pending",
confirmation_number=confirmation_number or "N/A",
filed_date=filed_date or "N/A",
ein_note=ein_note,
)
msg = email.mime.multipart.MIMEMultipart("mixed")
msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>"
msg["To"] = customer_email
msg["Subject"] = f"Your {type_display} Has Been Filed — {entity_name}"
msg["Reply-To"] = FROM_EMAIL
# HTML body
html_part = email.mime.text.MIMEText(html_body, "html", "utf-8")
msg.attach(html_part)
# Attach documents
for doc_path in documents:
path = Path(doc_path)
if not path.exists():
LOG.warning("Document not found, skipping: %s", doc_path)
continue
content_type, _ = mimetypes.guess_type(str(path))
if content_type is None:
content_type = "application/octet-stream"
maintype, subtype = content_type.split("/", 1)
with open(path, "rb") as f:
attachment = email.mime.application.MIMEApplication(f.read(), _subtype=subtype)
attachment.add_header(
"Content-Disposition",
"attachment",
filename=path.name,
)
msg.attach(attachment)
LOG.info("Attached: %s (%s)", path.name, content_type)
return msg
def send_delivery_email(
order_id: str,
customer_email: str,
customer_name: str,
documents: list[str],
) -> bool:
"""
Send formation documents to a customer and update order status.
Args:
order_id: The formation order ID.
customer_email: Customer's email address.
customer_name: Customer's display name.
documents: List of file paths to attach.
Returns:
True if email sent successfully, False otherwise.
"""
if not SMTP_HOST:
LOG.error("SMTP_HOST not configured — cannot send email.")
return False
conn = _get_connection()
try:
order = _fetch_order(conn, order_id)
if not order:
LOG.error("Order not found: %s", order_id)
return False
entity_name = order.get("entity_name", "")
entity_type = order.get("entity_type", "llc")
state_code = order.get("state_code", "")
filing_number = order.get("filing_number", "")
confirmation_number = order.get("confirmation_number", "")
filed_at = order.get("filed_at")
ein = order.get("ein", "") or ""
filed_date = ""
if filed_at:
if isinstance(filed_at, str):
filed_date = filed_at[:10]
elif isinstance(filed_at, datetime):
filed_date = filed_at.strftime("%Y-%m-%d")
msg = _build_email(
customer_email=customer_email,
customer_name=customer_name,
entity_name=entity_name,
entity_type=entity_type,
state_code=state_code,
filing_number=filing_number,
confirmation_number=confirmation_number,
filed_date=filed_date,
documents=documents,
ein=ein,
)
LOG.info(
"Sending delivery email to %s for order %s (%s)...",
customer_email,
order_id,
entity_name,
)
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
smtp.ehlo()
if SMTP_PORT != 25:
smtp.starttls()
smtp.ehlo()
if SMTP_USER and SMTP_PASS:
smtp.login(SMTP_USER, SMTP_PASS)
smtp.send_message(msg)
LOG.info("Email sent successfully to %s", customer_email)
# Mark order as delivered
_mark_delivered(conn, order_id)
LOG.info("Order %s marked as delivered", order_id)
return True
except smtplib.SMTPException as exc:
LOG.error("SMTP error sending to %s: %s", customer_email, exc)
return False
except Exception as exc:
LOG.error("Failed to send delivery email: %s", exc, exc_info=True)
return False
finally:
conn.close()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
"""CLI entry point: deliver documents for a specific order."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
if len(sys.argv) < 2:
print("Usage: python -m formation.document_delivery <order_id>")
print()
print("Fetches order details from the database, builds a delivery email,")
print("and sends it with attached documents.")
sys.exit(1)
order_id = sys.argv[1]
if not DATABASE_URL:
print("Error: DATABASE_URL not set.", file=sys.stderr)
sys.exit(1)
if not SMTP_HOST:
print("Error: SMTP_HOST not set.", file=sys.stderr)
sys.exit(1)
conn = _get_connection()
try:
order = _fetch_order(conn, order_id)
if not order:
print(f"Error: Order {order_id} not found.", file=sys.stderr)
sys.exit(1)
customer_email = order.get("customer_email", "")
customer_name = order.get("customer_name", "")
if not customer_email:
print(f"Error: No customer_email on order {order_id}.", file=sys.stderr)
sys.exit(1)
# Gather document paths
docs_raw = order.get("documents")
if isinstance(docs_raw, str):
docs_raw = json.loads(docs_raw)
documents = docs_raw or []
finally:
conn.close()
success = send_delivery_email(order_id, customer_email, customer_name, documents)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,666 @@
"""
ein_worker.py IRS EIN (Employer Identification Number) obtainment via the
IRS online application at https://sa.www4.irs.gov/modiein/individual/index.jsp
Uses Playwright to fill out the SS-4 equivalent online form and extracts the
assigned EIN from the confirmation page.
IMPORTANT: IRS online EIN is only available MonFri, 7:00 AM 10:00 PM ET.
Environment variables:
DATABASE_URL PostgreSQL connection string (optional, for order updates)
Usage:
# Standalone — obtain EIN for an order in the database
python -m formation.ein_worker <order_id>
# Called programmatically from formation_worker
from formation.ein_worker import obtain_ein
result = await obtain_ein(order)
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo
from playwright.async_api import async_playwright, Page
from .base import EntityType, FormationOrder, Member
LOG = logging.getLogger("formation.ein")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
IRS_EIN_URL = "https://sa.www4.irs.gov/modiein/individual/index.jsp"
SCREENSHOTS_DIR = Path(os.getenv("SCREENSHOTS_DIR", "/tmp/formation-screenshots"))
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------
@dataclass
class EINResult:
success: bool
ein: str = ""
confirmation_pdf: str = "" # Path to PDF screenshot
error_message: str = ""
timestamp: str = ""
def __post_init__(self):
if not self.timestamp:
self.timestamp = datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------
ET = ZoneInfo("America/New_York")
def is_irs_available() -> bool:
"""
Check if the IRS online EIN application is currently available.
Available MonFri, 7:00 AM 10:00 PM Eastern Time.
"""
now_et = datetime.now(ET)
weekday = now_et.weekday() # 0=Monday, 6=Sunday
hour = now_et.hour
if weekday >= 5: # Saturday or Sunday
return False
if hour < 7 or hour >= 22: # Before 7 AM or after 10 PM
return False
return True
def next_available_time() -> datetime:
"""Return the next datetime (ET) when the IRS EIN service will be available."""
now_et = datetime.now(ET)
# If currently available, return now
if is_irs_available():
return now_et
# Find next available slot
candidate = now_et.replace(hour=7, minute=0, second=0, microsecond=0)
if candidate <= now_et:
# Move to next day
from datetime import timedelta
candidate += timedelta(days=1)
# Skip weekends
while candidate.weekday() >= 5:
from datetime import timedelta
candidate += timedelta(days=1)
return candidate
# ---------------------------------------------------------------------------
# Helper: responsible party (first member / organizer)
# ---------------------------------------------------------------------------
def _get_responsible_party(order: FormationOrder) -> Member | None:
"""Get the responsible party for the EIN application."""
# Prefer the organizer
for m in order.members:
if m.is_organizer:
return m
# Fall back to first member
return order.members[0] if order.members else None
# ---------------------------------------------------------------------------
# Core EIN automation
# ---------------------------------------------------------------------------
async def obtain_ein(order: FormationOrder) -> EINResult:
"""
Obtain an EIN from the IRS online application for the given order.
Navigates the IRS EIN Assistant, fills out entity information, responsible
party details, and extracts the assigned EIN from the confirmation page.
Args:
order: FormationOrder with entity and member details.
Returns:
EINResult with the assigned EIN or error information.
"""
# Check availability
if not is_irs_available():
next_time = next_available_time()
return EINResult(
success=False,
error_message=(
f"IRS online EIN application is not currently available. "
f"Hours: MonFri 7 AM 10 PM ET. "
f"Next available: {next_time.strftime('%A %B %d, %Y at %I:%M %p ET')}"
),
)
responsible_party = _get_responsible_party(order)
if not responsible_party:
return EINResult(
success=False,
error_message="No members/responsible party found on order.",
)
LOG.info(
"[%s] Starting EIN application for %s (%s)",
order.order_id,
order.entity_name,
order.state_code,
)
pw = await async_playwright().start()
browser = await pw.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
)
context = await browser.new_context(
viewport={"width": 1280, "height": 900},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
),
locale="en-US",
timezone_id="America/New_York",
)
await context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
page = await context.new_page()
async def _screenshot(label: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
path = SCREENSHOTS_DIR / f"ein_{order.order_id}_{label}_{ts}.png"
await page.screenshot(path=str(path), full_page=True)
LOG.info("Screenshot: %s", path)
return str(path)
async def _delay(min_s: float = 1.0, max_s: float = 3.0):
import random
await asyncio.sleep(random.uniform(min_s, max_s))
try:
# Step 1: Navigate to IRS EIN Assistant
LOG.info("[%s] Navigating to IRS EIN Assistant...", order.order_id)
await page.goto(IRS_EIN_URL, wait_until="networkidle", timeout=30000)
await _delay(2, 4)
await _screenshot("01_landing")
# Step 2: Begin application — click "Begin Application" or "Apply Online Now"
begin_selectors = [
"input[value*='Begin Application']",
"a:has-text('Begin Application')",
"input[value*='Apply']",
"button:has-text('Begin')",
]
for sel in begin_selectors:
try:
el = await page.query_selector(sel)
if el:
await el.click()
break
except Exception:
continue
await _delay(2, 3)
# Step 3: Select entity type
LOG.info("[%s] Selecting entity type...", order.order_id)
if order.entity_type == EntityType.LLC:
# Select "Limited Liability Company (LLC)"
llc_selectors = [
"input[value*='LLC']",
"input[value*='limited liability']",
"label:has-text('Limited Liability Company')",
"input[type='radio'][id*='llc']",
]
for sel in llc_selectors:
try:
el = await page.query_selector(sel)
if el:
await el.click()
break
except Exception:
continue
elif order.entity_type in (EntityType.CORPORATION, EntityType.S_CORP):
corp_selectors = [
"input[value*='Corporation']",
"label:has-text('Corporation')",
"input[type='radio'][id*='corp']",
]
for sel in corp_selectors:
try:
el = await page.query_selector(sel)
if el:
await el.click()
break
except Exception:
continue
await _delay(1, 2)
# Click Continue/Next
await _click_continue(page)
await _delay(2, 3)
await _screenshot("02_entity_type")
# Step 4: Number of members (for LLC)
if order.entity_type == EntityType.LLC:
member_count = len(order.members)
if member_count <= 1:
# Single-member LLC
try:
await page.click("input[value*='1'], input[value*='single']")
except Exception:
pass
else:
# Multi-member LLC
try:
await page.click("input[value*='multi'], input[value*='More']")
except Exception:
pass
await _delay(1, 2)
await _click_continue(page)
await _delay(2, 3)
# Step 5: State of formation
LOG.info("[%s] Selecting state: %s", order.order_id, order.state_code)
state_select = await page.query_selector("select[name*='state'], select[id*='state']")
if state_select:
from .states import STATES
state_name = STATES.get(order.state_code.upper(), {}).get("name", order.state_code)
await state_select.select_option(label=state_name)
await _delay(1, 2)
await _click_continue(page)
await _delay(2, 3)
await _screenshot("03_state")
# Step 6: Reason for applying — "Started new business"
LOG.info("[%s] Selecting reason for applying...", order.order_id)
reason_selectors = [
"input[value*='Started']",
"input[value*='new business']",
"label:has-text('Started new business')",
"input[type='radio']:first-of-type",
]
for sel in reason_selectors:
try:
el = await page.query_selector(sel)
if el:
await el.click()
break
except Exception:
continue
await _delay(1, 2)
await _click_continue(page)
await _delay(2, 3)
await _screenshot("04_reason")
# Step 7: Entity information — name, address
LOG.info("[%s] Filling entity information...", order.order_id)
await _fill_field(page, "name", order.entity_name)
await _fill_field(page, "trade", order.entity_name) # DBA if asked
await _fill_field(page, "address", order.principal_address or responsible_party.address)
await _fill_field(page, "city", order.principal_city or responsible_party.city)
await _fill_field(page, "zip", order.principal_zip or responsible_party.zip_code)
# State dropdown for address
addr_state = order.principal_state or responsible_party.state
addr_state_selects = await page.query_selector_all("select")
for sel_el in addr_state_selects:
name_attr = await sel_el.get_attribute("name") or ""
id_attr = await sel_el.get_attribute("id") or ""
if "state" in name_attr.lower() or "state" in id_attr.lower():
try:
await sel_el.select_option(value=addr_state)
except Exception:
try:
from .states import STATES as _S
sn = _S.get(addr_state.upper(), {}).get("name", addr_state)
await sel_el.select_option(label=sn)
except Exception:
pass
break
await _delay(1, 2)
await _click_continue(page)
await _delay(2, 3)
await _screenshot("05_entity_info")
# Step 8: Responsible party information
LOG.info("[%s] Filling responsible party: %s", order.order_id, responsible_party.name)
name_parts = responsible_party.name.split(None, 1)
first_name = name_parts[0] if name_parts else ""
last_name = name_parts[1] if len(name_parts) > 1 else ""
await _fill_field(page, "first", first_name)
await _fill_field(page, "last", last_name)
# SSN/ITIN — these would be provided securely; placeholder for the field
# In production, SSN is passed through secure order data (not stored in plain text)
ssn = getattr(order, "_responsible_party_ssn", "")
if ssn:
ssn_fields = await page.query_selector_all("input[type='text'][maxlength='3'], input[type='text'][maxlength='2'], input[type='text'][maxlength='4']")
ssn_digits = re.sub(r"\D", "", ssn)
if len(ssn_digits) == 9 and len(ssn_fields) >= 3:
await ssn_fields[0].fill(ssn_digits[:3])
await _delay(0.3, 0.6)
await ssn_fields[1].fill(ssn_digits[3:5])
await _delay(0.3, 0.6)
await ssn_fields[2].fill(ssn_digits[5:])
await _delay(1, 2)
await _click_continue(page)
await _delay(2, 3)
await _screenshot("06_responsible_party")
# Step 9: Additional questions — date started, fiscal year, etc.
LOG.info("[%s] Filling additional details...", order.order_id)
today_str = datetime.now().strftime("%m/%d/%Y")
await _fill_field(page, "date", order.effective_date or today_str)
await _fill_field(page, "closing", order.fiscal_year_end or "December")
# Number of employees expected (select "0" or "No employees planned")
await _fill_field(page, "employee", "0")
await _delay(1, 2)
await _click_continue(page)
await _delay(2, 3)
await _screenshot("07_additional")
# Step 10: Review and submit
LOG.info("[%s] Reviewing and submitting application...", order.order_id)
await _screenshot("08_review")
submit_selectors = [
"input[value*='Submit']",
"button:has-text('Submit')",
"input[type='submit']",
]
for sel in submit_selectors:
try:
el = await page.query_selector(sel)
if el:
await el.click()
break
except Exception:
continue
await _delay(3, 5)
await _screenshot("09_submitted")
# Step 11: Extract EIN from confirmation page
LOG.info("[%s] Extracting EIN from confirmation...", order.order_id)
page_text = await page.inner_text("body")
# EIN format: XX-XXXXXXX
ein_match = re.search(r"\b(\d{2}-\d{7})\b", page_text)
if not ein_match:
# Try without hyphen
ein_match = re.search(r"EIN[:\s]*(\d{9})", page_text, re.IGNORECASE)
if ein_match:
ein = ein_match.group(1)
# Normalize to XX-XXXXXXX format
if "-" not in ein and len(ein) == 9:
ein = f"{ein[:2]}-{ein[2:]}"
LOG.info("[%s] EIN obtained: %s", order.order_id, ein)
else:
LOG.error("[%s] Could not extract EIN from confirmation page", order.order_id)
await _screenshot("09_no_ein_found")
return EINResult(
success=False,
error_message="Could not extract EIN from IRS confirmation page.",
confirmation_pdf=await _save_confirmation_pdf(page, order.order_id),
)
# Save confirmation as PDF
confirmation_pdf = await _save_confirmation_pdf(page, order.order_id)
await _screenshot("10_confirmation")
return EINResult(
success=True,
ein=ein,
confirmation_pdf=confirmation_pdf,
)
except Exception as exc:
LOG.error("[%s] EIN application failed: %s", order.order_id, exc, exc_info=True)
try:
await _screenshot("error")
except Exception:
pass
return EINResult(
success=False,
error_message=str(exc),
)
finally:
await context.close()
await browser.close()
# ---------------------------------------------------------------------------
# Page interaction helpers
# ---------------------------------------------------------------------------
async def _fill_field(page: Page, name_hint: str, value: str):
"""
Attempt to fill a form field matching a name/id hint.
Tries multiple selector strategies.
"""
if not value:
return
selectors = [
f"input[name*='{name_hint}' i]",
f"input[id*='{name_hint}' i]",
f"textarea[name*='{name_hint}' i]",
f"select[name*='{name_hint}' i]",
]
for sel in selectors:
try:
el = await page.query_selector(sel)
if el:
tag = await el.evaluate("e => e.tagName.toLowerCase()")
if tag == "select":
try:
await el.select_option(label=value)
except Exception:
await el.select_option(value=value)
else:
await el.fill(value)
return
except Exception:
continue
async def _click_continue(page: Page):
"""Click the Continue/Next/Submit button on the current IRS page."""
selectors = [
"input[value='Continue']",
"input[value='Next']",
"input[value*='Continue']",
"button:has-text('Continue')",
"button:has-text('Next')",
"input[type='submit']",
]
for sel in selectors:
try:
el = await page.query_selector(sel)
if el and await el.is_visible():
await el.click()
return
except Exception:
continue
async def _save_confirmation_pdf(page: Page, order_id: str) -> str:
"""Save the current page as a PDF screenshot for records."""
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_dir = Path(f"/tmp/formations/{order_id}")
output_dir.mkdir(parents=True, exist_ok=True)
pdf_path = output_dir / f"ein_confirmation_{ts}.pdf"
try:
await page.pdf(path=str(pdf_path))
LOG.info("EIN confirmation PDF saved: %s", pdf_path)
except Exception:
# PDF generation only works in headless Chromium; fall back to screenshot
png_path = output_dir / f"ein_confirmation_{ts}.png"
await page.screenshot(path=str(png_path), full_page=True)
LOG.info("EIN confirmation screenshot saved (PDF fallback): %s", png_path)
return str(png_path)
return str(pdf_path)
# ---------------------------------------------------------------------------
# Database update
# ---------------------------------------------------------------------------
def _update_order_ein(order_id: str, ein: str, confirmation_pdf: str):
"""Update the formation_orders table with the obtained EIN."""
if not DATABASE_URL:
LOG.warning("DATABASE_URL not set — skipping order update for EIN")
return
import psycopg2
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE formation_orders
SET ein = %s,
ein_confirmation = %s,
updated_at = NOW()
WHERE order_id = %s
""",
(ein, confirmation_pdf, order_id),
)
conn.commit()
LOG.info("Updated order %s with EIN %s", order_id, ein)
finally:
conn.close()
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
async def _main_standalone(order_id: str):
"""Fetch order from DB and obtain EIN."""
if not DATABASE_URL:
print("Error: DATABASE_URL not set.", file=sys.stderr)
sys.exit(1)
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
row = cur.fetchone()
finally:
conn.close()
if not row:
print(f"Error: Order {order_id} not found.", file=sys.stderr)
sys.exit(1)
# Build FormationOrder from row
members_raw = row.get("members")
if isinstance(members_raw, str):
members_raw = json.loads(members_raw)
elif members_raw is None:
members_raw = []
members = [
Member(
name=m.get("name", ""),
address=m.get("address", ""),
city=m.get("city", ""),
state=m.get("state", ""),
zip_code=m.get("zip_code", ""),
title=m.get("title", "Member"),
ownership_pct=float(m.get("ownership_pct", 0)),
is_organizer=bool(m.get("is_organizer", False)),
)
for m in members_raw
]
try:
entity_type = EntityType(row.get("entity_type", "llc"))
except ValueError:
entity_type = EntityType.LLC
order = FormationOrder(
order_id=str(row["order_id"]),
state_code=row.get("state_code", ""),
entity_type=entity_type,
entity_name=row.get("entity_name", ""),
members=members,
principal_address=row.get("principal_address", ""),
principal_city=row.get("principal_city", ""),
principal_state=row.get("principal_state", ""),
principal_zip=row.get("principal_zip", ""),
fiscal_year_end=row.get("fiscal_year_end", "12/31"),
effective_date=row.get("effective_date", "") or "",
)
# Check availability first
if not is_irs_available():
next_time = next_available_time()
print(
f"IRS EIN online service is currently unavailable.\n"
f"Hours: MonFri, 7:00 AM 10:00 PM ET\n"
f"Next available: {next_time.strftime('%A %B %d, %Y at %I:%M %p ET')}"
)
sys.exit(1)
result = await obtain_ein(order)
if result.success:
print(f"EIN obtained: {result.ein}")
print(f"Confirmation: {result.confirmation_pdf}")
_update_order_ein(order.order_id, result.ein, result.confirmation_pdf)
else:
print(f"EIN application failed: {result.error_message}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
if len(sys.argv) < 2:
print("Usage: python -m formation.ein_worker <order_id>")
print()
print("Obtains an EIN from the IRS online application for the given order.")
print()
print("Note: IRS online EIN is only available MonFri, 7 AM 10 PM ET.")
sys.exit(1)
asyncio.run(_main_standalone(sys.argv[1]))

View file

@ -0,0 +1,563 @@
"""
formation_worker.py Order queue processor for business formation filings.
Polls the PostgreSQL `formation_orders` table for new orders and processes them
through the appropriate state adapter. Designed to run as a long-lived daemon
with single-instance locking.
Features:
- Polls for orders with status='received' every 60 seconds
- Configurable human-paced delays between orders
- Single-instance locking via fcntl.flock
- Structured logging to ~/logs/formation-worker.log
- ERPNext Issue creation on errors
Environment variables:
DATABASE_URL PostgreSQL connection string
FORMATION_DELAY_MIN Minimum delay between orders in minutes (default: 30)
FORMATION_DELAY_MAX Maximum delay between orders in minutes (default: 120)
Usage:
python -m formation.formation_worker
"""
from __future__ import annotations
import asyncio
import fcntl
import json
import logging
import os
import random
import signal
import sys
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
import psycopg2
import psycopg2.extras
from .base import (
EntityType,
FilingResult,
FilingStatus,
FormationOrder,
Member,
NameSearchResult,
)
from .states import get_adapter, STATES
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DATABASE_URL = os.environ.get("DATABASE_URL", "")
POLL_INTERVAL_SECONDS = 60
DELAY_MIN_MINUTES = int(os.environ.get("FORMATION_DELAY_MIN", "30"))
DELAY_MAX_MINUTES = int(os.environ.get("FORMATION_DELAY_MAX", "120"))
LOCK_FILE = "/tmp/formation-worker.lock"
LOG_DIR = Path.home() / "logs"
LOG_FILE = LOG_DIR / "formation-worker.log"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG = logging.getLogger("formation.worker")
_file_handler = logging.FileHandler(str(LOG_FILE))
_file_handler.setFormatter(
logging.Formatter("%(asctime)s [%(name)s] %(levelname)s %(message)s")
)
_stream_handler = logging.StreamHandler(sys.stdout)
_stream_handler.setFormatter(
logging.Formatter("%(asctime)s [%(name)s] %(levelname)s %(message)s")
)
LOG.addHandler(_file_handler)
LOG.addHandler(_stream_handler)
LOG.setLevel(logging.INFO)
# ---------------------------------------------------------------------------
# Alerting (ERPNext Issues)
# ---------------------------------------------------------------------------
def _alert_error(order_id: str, state_code: str, error: str, detail: str = ""):
"""Create an ERPNext Issue for a formation processing error."""
try:
# Import the shared alert module (one level up)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from alert import alert_account_broken
alert_account_broken(
monitor="formation-worker",
platform=f"SOS-{state_code}",
error=f"Formation order {order_id} failed: {error}",
detail=detail,
)
except Exception as exc:
LOG.warning("Failed to send alert for order %s: %s", order_id, exc)
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def _get_connection():
"""Open a PostgreSQL connection from DATABASE_URL."""
if not DATABASE_URL:
raise RuntimeError(
"DATABASE_URL environment variable is not set. "
"Expected format: postgresql://user:pass@host:5432/dbname"
)
return psycopg2.connect(DATABASE_URL)
def _fetch_pending_orders(conn) -> list[dict]:
"""Fetch all orders with status='received', oldest first."""
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT *
FROM formation_orders
WHERE status = 'received'
ORDER BY created_at ASC
LIMIT 10
"""
)
return [dict(row) for row in cur.fetchall()]
def _update_order_status(
conn,
order_id: str,
status: str,
*,
filing_number: str = "",
confirmation_number: str = "",
error_message: str = "",
screenshots: list[str] | None = None,
documents: list[str] | None = None,
ein: str = "",
):
"""Update an order's status and related fields."""
fields = ["status = %s", "updated_at = NOW()"]
values: list = [status]
if filing_number:
fields.append("filing_number = %s")
values.append(filing_number)
if confirmation_number:
fields.append("confirmation_number = %s")
values.append(confirmation_number)
if error_message:
fields.append("error_message = %s")
values.append(error_message)
if screenshots is not None:
fields.append("screenshots = %s")
values.append(json.dumps(screenshots))
if documents is not None:
fields.append("documents = %s")
values.append(json.dumps(documents))
if ein:
fields.append("ein = %s")
values.append(ein)
if status == "filed":
fields.append("filed_at = NOW()")
values.append(order_id)
with conn.cursor() as cur:
cur.execute(
f"UPDATE formation_orders SET {', '.join(fields)} WHERE order_id = %s",
values,
)
conn.commit()
def _update_automation_status(conn, order_id: str, auto_status: str, *, error: str | None = None):
"""Update the automation_status and related fields."""
fields = ["automation_status = %s", "last_activity_at = NOW()"]
values: list = [auto_status]
if error:
fields.append("automation_error = %s")
values.append(error)
values.append(order_id)
with conn.cursor() as cur:
cur.execute(
f"UPDATE formation_orders SET {', '.join(fields)} WHERE id = %s",
values,
)
conn.commit()
def _increment_attempts(conn, order_id: str):
"""Increment the automation_attempts counter."""
with conn.cursor() as cur:
cur.execute(
"UPDATE formation_orders SET automation_attempts = automation_attempts + 1 WHERE id = %s",
[order_id],
)
conn.commit()
def _write_audit(
conn,
order_id,
order_number: str,
action: str,
from_status: str = "",
to_status: str = "",
actor_type: str = "worker",
*,
note: str = "",
metadata: dict | None = None,
):
"""Write an entry to the order_audit_log table."""
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO order_audit_log
(order_type, order_id, order_number, action, from_status, to_status,
actor_type, actor_name, note, metadata)
VALUES ('formation', %s, %s, %s, %s, %s, %s, 'formation_worker', %s, %s)""",
[order_id, order_number, action, from_status or None, to_status or None,
actor_type, note or None, json.dumps(metadata) if metadata else None],
)
conn.commit()
def _row_to_order(row: dict) -> FormationOrder:
"""Convert a database row dict to a FormationOrder dataclass."""
members_raw = row.get("members")
if isinstance(members_raw, str):
members_raw = json.loads(members_raw)
elif members_raw is None:
members_raw = []
members = []
for m in members_raw:
members.append(
Member(
name=m.get("name", ""),
address=m.get("address", ""),
city=m.get("city", ""),
state=m.get("state", ""),
zip_code=m.get("zip_code", ""),
title=m.get("title", "Member"),
ownership_pct=float(m.get("ownership_pct", 0)),
is_organizer=bool(m.get("is_organizer", False)),
)
)
entity_type_raw = row.get("entity_type", "llc")
try:
entity_type = EntityType(entity_type_raw)
except ValueError:
entity_type = EntityType.LLC
return FormationOrder(
order_id=str(row["order_id"]),
state_code=row.get("state_code", ""),
entity_type=entity_type,
entity_name=row.get("entity_name", ""),
entity_name_alt=row.get("entity_name_alt", ""),
management_type=row.get("management_type", "member_managed"),
purpose=row.get("purpose", "Any lawful business activity"),
members=members,
registered_agent_name=row.get("registered_agent_name", "Northwest Registered Agent"),
registered_agent_address=row.get("registered_agent_address", ""),
principal_address=row.get("principal_address", ""),
principal_city=row.get("principal_city", ""),
principal_state=row.get("principal_state", ""),
principal_zip=row.get("principal_zip", ""),
mailing_address=row.get("mailing_address", ""),
mailing_city=row.get("mailing_city", ""),
mailing_state=row.get("mailing_state", ""),
mailing_zip=row.get("mailing_zip", ""),
shares_authorized=int(row.get("shares_authorized", 1500)),
par_value=float(row.get("par_value", 0.0)),
fiscal_year_end=row.get("fiscal_year_end", "12/31"),
expedited=bool(row.get("expedited", False)),
effective_date=row.get("effective_date", "") or "",
)
# ---------------------------------------------------------------------------
# Core processing
# ---------------------------------------------------------------------------
async def process_order(order: FormationOrder, conn) -> FilingResult:
"""
Process a single formation order:
1. Verify name availability
2. File the entity
3. Return the result
"""
state_code = order.state_code.upper()
LOG.info(
"Processing order %s: %s in %s (%s)",
order.order_id,
order.entity_name,
state_code,
order.entity_type.value,
)
adapter = get_adapter(state_code)
try:
await adapter.start_browser(headless=True)
# Step 1: Verify name availability
LOG.info("[%s] Searching name: %s", order.order_id, order.entity_name)
name_result: NameSearchResult = await adapter.search_name(order.entity_name)
if not name_result.available:
# Try alternate name if provided
if order.entity_name_alt:
LOG.info(
"[%s] Primary name unavailable, trying alternate: %s",
order.order_id,
order.entity_name_alt,
)
name_result = await adapter.search_name(order.entity_name_alt)
if name_result.available:
order.entity_name = order.entity_name_alt
else:
return FilingResult(
success=False,
status=FilingStatus.NAME_UNAVAILABLE,
state_code=state_code,
entity_name=order.entity_name,
error_message=(
f"Both names unavailable: '{order.entity_name}' "
f"and '{order.entity_name_alt}'"
),
screenshot_path=await adapter.screenshot("name_unavailable"),
)
else:
return FilingResult(
success=False,
status=FilingStatus.NAME_UNAVAILABLE,
state_code=state_code,
entity_name=order.entity_name,
error_message=f"Name unavailable: '{order.entity_name}'",
screenshot_path=await adapter.screenshot("name_unavailable"),
)
LOG.info("[%s] Name available: %s", order.order_id, order.entity_name)
# Step 2: File the entity
LOG.info("[%s] Filing entity...", order.order_id)
result: FilingResult = await adapter.file_entity(order)
LOG.info(
"[%s] Filing result: %s (filing_number=%s, confirmation=%s)",
order.order_id,
result.status.value,
result.filing_number,
result.confirmation_number,
)
return result
except Exception as exc:
LOG.error(
"[%s] Unhandled error processing order: %s",
order.order_id,
exc,
exc_info=True,
)
screenshot = ""
try:
screenshot = await adapter.screenshot("error")
except Exception:
pass
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=state_code,
entity_name=order.entity_name,
error_message=str(exc),
screenshot_path=screenshot,
)
finally:
await adapter.close_browser()
async def poll_and_process():
"""Single poll iteration: fetch pending orders and process them."""
conn = _get_connection()
try:
orders = _fetch_pending_orders(conn)
if not orders:
return
LOG.info("Found %d pending order(s)", len(orders))
for i, row in enumerate(orders):
order = _row_to_order(row)
# Mark as processing + set automation_status to running
_update_order_status(conn, order.order_id, "processing")
_update_automation_status(conn, order.order_id, "running")
_write_audit(conn, order.order_id, row.get("order_number", ""),
"status_change", "received", "processing",
"worker", note="Automation started")
# Process the order
result = await process_order(order, conn)
# Map result to DB status
if result.status == FilingStatus.FILED:
db_status = "filed"
auto_status = "succeeded"
elif result.status == FilingStatus.SUBMITTED:
db_status = "submitted"
auto_status = "running"
elif result.status == FilingStatus.NAME_UNAVAILABLE:
db_status = "received" # Keep in queue for admin review
auto_status = "failed"
else:
db_status = "received" # Keep in queue for manual intervention
auto_status = "failed"
# Update the order
screenshots = [result.screenshot_path] if result.screenshot_path else []
_update_order_status(
conn,
order.order_id,
db_status,
filing_number=result.filing_number,
confirmation_number=result.confirmation_number,
error_message=result.error_message,
screenshots=screenshots,
documents=result.documents,
)
_update_automation_status(
conn, order.order_id, auto_status,
error=result.error_message if auto_status == "failed" else None,
)
_write_audit(
conn, order.order_id, row.get("order_number", ""),
"automation_update" if auto_status != "succeeded" else "status_change",
"processing", db_status, "worker",
note=result.error_message if auto_status == "failed"
else f"Filed: {result.filing_number}" if result.filing_number
else f"Status: {db_status}",
metadata={"filing_number": result.filing_number,
"confirmation": result.confirmation_number,
"screenshot": result.screenshot_path} if result.filing_number else None,
)
if auto_status == "failed":
# Increment attempt counter and alert
_increment_attempts(conn, order.order_id)
_alert_error(
order.order_id,
order.state_code,
result.error_message,
detail=f"Entity: {order.entity_name}\nState: {order.state_code}\n"
f"Filing number: {result.filing_number}\n"
f"Screenshot: {result.screenshot_path}\n"
f"Status set to: {auto_status}\n"
f"Order returned to queue for manual intervention.",
)
# Human-paced delay between orders (skip after last order)
if i < len(orders) - 1:
delay_minutes = random.uniform(DELAY_MIN_MINUTES, DELAY_MAX_MINUTES)
delay_seconds = delay_minutes * 60
LOG.info(
"Waiting %.1f minutes before next order (human-paced delay)...",
delay_minutes,
)
await asyncio.sleep(delay_seconds)
finally:
conn.close()
# ---------------------------------------------------------------------------
# Main loop with single-instance locking
# ---------------------------------------------------------------------------
_shutdown = False
def _handle_signal(signum, frame):
global _shutdown
LOG.info("Received signal %d, shutting down gracefully...", signum)
_shutdown = True
async def run_worker():
"""Main worker loop: poll for orders, process them, sleep, repeat."""
LOG.info("=" * 60)
LOG.info("Formation worker starting")
LOG.info(" Poll interval: %ds", POLL_INTERVAL_SECONDS)
LOG.info(" Delay range: %d%d minutes", DELAY_MIN_MINUTES, DELAY_MAX_MINUTES)
LOG.info(" Log file: %s", LOG_FILE)
LOG.info("=" * 60)
while not _shutdown:
try:
await poll_and_process()
except Exception as exc:
LOG.error("Poll cycle failed: %s", exc, exc_info=True)
_alert_error("N/A", "N/A", f"Poll cycle failed: {exc}", traceback.format_exc())
# Sleep in short increments so we can respond to shutdown signals
for _ in range(POLL_INTERVAL_SECONDS):
if _shutdown:
break
await asyncio.sleep(1)
LOG.info("Formation worker stopped.")
def main():
"""Entry point with single-instance locking."""
if not DATABASE_URL:
print(
"Error: DATABASE_URL environment variable is not set.",
file=sys.stderr,
)
sys.exit(1)
# Acquire single-instance lock
lock_fd = open(LOCK_FILE, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print(
"Error: Another formation worker is already running (lock held).",
file=sys.stderr,
)
sys.exit(1)
# Write PID to lock file
lock_fd.write(str(os.getpid()))
lock_fd.flush()
# Register signal handlers
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
try:
asyncio.run(run_worker())
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
try:
os.unlink(LOCK_FILE)
except OSError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,304 @@
"""
Holiday calendar for web automation scheduling.
Covers:
- US federal holidays (observed) relevant to IRS, SSA, and US state portals
- Canadian federal statutory holidays relevant to CRTC, BC Registry
- BC provincial holidays relevant to BC Corporate Registry, BC Online
- State-specific observed holidays (closures vary by SOS office)
Usage:
from scripts.formation.holidays import is_holiday, next_business_day
if is_holiday(date.today(), jurisdiction="US"):
...
if is_holiday(date.today(), jurisdiction="BC"):
...
"""
from datetime import date, timedelta
from typing import Literal, Optional
Jurisdiction = Literal["US", "CA", "BC", "IRS"]
# ── Helpers ────────────────────────────────────────────────────────────────────
def _nth_weekday(year: int, month: int, weekday: int, n: int) -> date:
"""Return the nth occurrence of weekday (Mon=0 … Sun=6) in given month/year.
n=1 first, n=2 second, n=-1 last.
"""
if n > 0:
first = date(year, month, 1)
offset = (weekday - first.weekday()) % 7
return first + timedelta(days=offset + 7 * (n - 1))
else: # last
# find last day, walk back
if month == 12:
last = date(year + 1, 1, 1) - timedelta(days=1)
else:
last = date(year, month + 1, 1) - timedelta(days=1)
offset = (last.weekday() - weekday) % 7
return last - timedelta(days=offset)
def _observed(d: date) -> date:
"""Return the observed date for a holiday falling on a weekend.
Saturday Friday, Sunday Monday.
"""
if d.weekday() == 5: # Saturday
return d - timedelta(days=1)
if d.weekday() == 6: # Sunday
return d + timedelta(days=1)
return d
# ── US Federal Holidays ────────────────────────────────────────────────────────
def _us_federal_holidays(year: int) -> set[date]:
"""Return the set of US federal holiday observed dates for a given year."""
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
holidays = set()
# New Year's Day — Jan 1
holidays.add(_observed(date(year, 1, 1)))
# Martin Luther King Jr. Day — 3rd Monday in January
holidays.add(_nth_weekday(year, 1, MON, 3))
# Presidents' Day (Washington's Birthday) — 3rd Monday in February
holidays.add(_nth_weekday(year, 2, MON, 3))
# Memorial Day — last Monday in May
holidays.add(_nth_weekday(year, 5, MON, -1))
# Juneteenth — June 19
holidays.add(_observed(date(year, 6, 19)))
# Independence Day — July 4
holidays.add(_observed(date(year, 7, 4)))
# Labor Day — 1st Monday in September
holidays.add(_nth_weekday(year, 9, MON, 1))
# Columbus Day — 2nd Monday in October
holidays.add(_nth_weekday(year, 10, MON, 2))
# Veterans Day — November 11
holidays.add(_observed(date(year, 11, 11)))
# Thanksgiving — 4th Thursday in November
holidays.add(_nth_weekday(year, 11, THU, 4))
# Christmas — December 25
holidays.add(_observed(date(year, 12, 25)))
# New Year's Day (observed for next year, sometimes Dec 31)
ny_next = _observed(date(year + 1, 1, 1))
if ny_next.year == year:
holidays.add(ny_next)
return holidays
# ── Canadian Federal Statutory Holidays ───────────────────────────────────────
def _canada_federal_holidays(year: int) -> set[date]:
"""Return Canadian federal statutory holiday dates for a given year."""
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
holidays = set()
# New Year's Day
holidays.add(_observed(date(year, 1, 1)))
# Good Friday — Friday before Easter
easter = _easter(year)
holidays.add(easter - timedelta(days=2)) # Good Friday
# Easter Monday
holidays.add(easter + timedelta(days=1))
# Victoria Day — Monday before May 25
may25 = date(year, 5, 25)
days_since_mon = may25.weekday() # Mon=0
if days_since_mon == 0:
holidays.add(may25 - timedelta(days=7))
else:
holidays.add(may25 - timedelta(days=days_since_mon))
# Canada Day — July 1
holidays.add(_observed(date(year, 7, 1)))
# Labour Day — 1st Monday in September
holidays.add(_nth_weekday(year, 9, MON, 1))
# National Day for Truth and Reconciliation — Sept 30 (federal)
holidays.add(_observed(date(year, 9, 30)))
# Thanksgiving — 2nd Monday in October
holidays.add(_nth_weekday(year, 10, MON, 2))
# Remembrance Day — November 11
holidays.add(_observed(date(year, 11, 11)))
# Christmas Day — December 25
holidays.add(_observed(date(year, 12, 25)))
# Boxing Day — December 26
holidays.add(_observed(date(year, 12, 26)))
return holidays
# ── BC Provincial Holidays ────────────────────────────────────────────────────
def _bc_holidays(year: int) -> set[date]:
"""Return BC provincial statutory holidays (superset of Canadian federal)."""
MON = 0
holidays = _canada_federal_holidays(year)
# BC Day — 1st Monday in August
holidays.add(_nth_weekday(year, 8, MON, 1))
# Family Day — 3rd Monday in February (BC-specific — started 2013)
if year >= 2013:
holidays.add(_nth_weekday(year, 2, MON, 3))
return holidays
# ── Easter (Gregorian) ────────────────────────────────────────────────────────
def _easter(year: int) -> date:
"""Return date of Easter Sunday using the Anonymous Gregorian algorithm."""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
# ── Cache ─────────────────────────────────────────────────────────────────────
_cache: dict[tuple[int, str], set[date]] = {}
def _get_holidays(year: int, jurisdiction: Jurisdiction) -> set[date]:
key = (year, jurisdiction)
if key not in _cache:
if jurisdiction == "US" or jurisdiction == "IRS":
_cache[key] = _us_federal_holidays(year)
elif jurisdiction == "CA":
_cache[key] = _canada_federal_holidays(year)
elif jurisdiction == "BC":
_cache[key] = _bc_holidays(year)
else:
_cache[key] = set()
return _cache[key]
# ── Public API ────────────────────────────────────────────────────────────────
def is_holiday(d: date, jurisdiction: Jurisdiction = "US") -> bool:
"""Return True if `d` is a holiday in the given jurisdiction."""
return d in _get_holidays(d.year, jurisdiction)
def is_weekend(d: date) -> bool:
"""Return True if `d` is Saturday or Sunday."""
return d.weekday() >= 5
def is_business_day(d: date, jurisdiction: Jurisdiction = "US") -> bool:
"""Return True if `d` is a weekday and not a holiday."""
return not is_weekend(d) and not is_holiday(d, jurisdiction)
def next_business_day(
after: Optional[date] = None,
jurisdiction: Jurisdiction = "US",
) -> date:
"""Return the next business day after `after` (default: today)."""
d = (after or date.today()) + timedelta(days=1)
while not is_business_day(d, jurisdiction):
d += timedelta(days=1)
return d
def holiday_name(d: date, jurisdiction: Jurisdiction = "US") -> Optional[str]:
"""Return a human-readable name for the holiday on `d`, or None."""
# Build a labelled lookup for the year
labels = _labelled_holidays(d.year, jurisdiction)
return labels.get(d)
def _labelled_holidays(year: int, jurisdiction: Jurisdiction) -> dict[date, str]:
"""Return holiday dates mapped to their names."""
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
result: dict[date, str] = {}
if jurisdiction in ("US", "IRS"):
result[_observed(date(year, 1, 1))] = "New Year's Day"
result[_nth_weekday(year, 1, MON, 3)] = "Martin Luther King Jr. Day"
result[_nth_weekday(year, 2, MON, 3)] = "Presidents' Day"
result[_nth_weekday(year, 5, MON, -1)] = "Memorial Day"
result[_observed(date(year, 6, 19))] = "Juneteenth"
result[_observed(date(year, 7, 4))] = "Independence Day"
result[_nth_weekday(year, 9, MON, 1)] = "Labor Day"
result[_nth_weekday(year, 10, MON, 2)] = "Columbus Day"
result[_observed(date(year, 11, 11))] = "Veterans Day"
result[_nth_weekday(year, 11, THU, 4)] = "Thanksgiving"
result[_observed(date(year, 12, 25))] = "Christmas Day"
ny_next = _observed(date(year + 1, 1, 1))
if ny_next.year == year:
result[ny_next] = "New Year's Day (observed)"
elif jurisdiction in ("CA", "BC"):
easter = _easter(year)
result[_observed(date(year, 1, 1))] = "New Year's Day"
result[easter - timedelta(days=2)] = "Good Friday"
result[easter + timedelta(days=1)] = "Easter Monday"
may25 = date(year, 5, 25)
daysback = may25.weekday() if may25.weekday() > 0 else 7
result[may25 - timedelta(days=daysback)] = "Victoria Day"
result[_observed(date(year, 7, 1))] = "Canada Day"
result[_nth_weekday(year, 9, MON, 1)] = "Labour Day"
result[_observed(date(year, 9, 30))] = "National Day for Truth and Reconciliation"
result[_nth_weekday(year, 10, MON, 2)] = "Thanksgiving"
result[_observed(date(year, 11, 11))] = "Remembrance Day"
result[_observed(date(year, 12, 25))] = "Christmas Day"
result[_observed(date(year, 12, 26))] = "Boxing Day"
if jurisdiction == "BC":
result[_nth_weekday(year, 8, MON, 1)] = "BC Day"
if year >= 2013:
result[_nth_weekday(year, 2, MON, 3)] = "Family Day (BC)"
return result
def upcoming_holidays(
days: int = 30,
jurisdiction: Jurisdiction = "US",
from_date: Optional[date] = None,
) -> list[tuple[date, str]]:
"""Return (date, name) pairs for holidays in the next `days` days."""
start = from_date or date.today()
end = start + timedelta(days=days)
labelled = _labelled_holidays(start.year, jurisdiction)
if end.year != start.year:
labelled.update(_labelled_holidays(end.year, jurisdiction))
return sorted(
[(d, name) for d, name in labelled.items() if start <= d <= end],
key=lambda x: x[0],
)

View file

@ -0,0 +1,313 @@
"""Unified jurisdiction abstraction for US states + Canadian provinces.
This module sits alongside the legacy `scripts.formation.states` registry
and reads from the `jurisdictions` Postgres table (migration 066). It's
the canonical source of jurisdiction metadata going forward.
Why we have both:
- `scripts.formation.states` still owns per-jurisdiction Playwright
adapters (`adapter.py` + `config.py` per state) because those files
contain the hand-written CSS selectors + portal-specific flows.
- `scripts.formation.jurisdictions` owns the *data* (currency, country,
entity types, portal URL, NWRA wholesale pricing) that's
jurisdiction-agnostic and read from the DB.
The two are joined by state code. `JurisdictionConfig.adapter()` returns
the legacy adapter for a code so callers don't have to care.
Usage:
from scripts.formation.jurisdictions import get_jurisdiction
j = get_jurisdiction("WY")
j.country # 'US'
j.currency # 'USD'
j.entity_types # [{'code':'llc','label':'LLC'}, ...]
j.foreign_qualification_fee_cents("llc") # from state_filing_fees
adapter = j.adapter() # legacy StatePortal instance
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Optional
import psycopg2
import psycopg2.extras
LOG = logging.getLogger("formation.jurisdictions")
# ────────────────────────────────────────────────────────────────────── #
# Data classes
# ────────────────────────────────────────────────────────────────────── #
@dataclass
class EntityTypeSpec:
"""One entity type a jurisdiction recognizes."""
code: str # 'llc' | 'corporation' | 'ltd' | 'inc' | ...
label: str
@dataclass
class JurisdictionConfig:
"""Unified config for a single US state / DC / Canadian province.
Fields mirror the `jurisdictions` table. `state_filing_fees` data is
lazy-loaded via the helper methods so we don't pay a second DB hit
on every access.
"""
code: str
name: str
country: str # 'US' | 'CA'
kind: str # 'state' | 'district' | 'province' | 'territory'
currency: str # 'USD' | 'CAD'
timezone: Optional[str] = None
portal_name: Optional[str] = None
portal_url: Optional[str] = None
portal_login_required: bool = False
entity_types: list[EntityTypeSpec] = field(default_factory=list)
supports_foreign_qualification: bool = True
foreign_qual_portal_url: Optional[str] = None
foreign_qual_requires_coa: bool = True
nwra_foreign_qual_wholesale_cents: Optional[int] = None
notes: Optional[str] = None
# ────────────────────────────────────────────────────────────────── #
# Fee lookups — read on demand from state_filing_fees
# ────────────────────────────────────────────────────────────────── #
def foreign_qualification_fee_cents(self, entity_type: str) -> Optional[int]:
"""Return target-state's foreign qualification fee for this entity type."""
col = _FOREIGN_QUAL_FEE_COL.get(_normalize_entity_type(entity_type))
if not col:
return None
row = _query_one(
f"SELECT {col} AS fee FROM state_filing_fees WHERE state_code = %s",
(self.code,),
)
return int(row["fee"]) if row and row["fee"] is not None else None
def formation_fee_cents(self, entity_type: str) -> Optional[int]:
"""Return home-state's formation fee for this entity type."""
col = _FORMATION_FEE_COL.get(_normalize_entity_type(entity_type))
if not col:
return None
row = _query_one(
f"SELECT {col} AS fee FROM state_filing_fees WHERE state_code = %s",
(self.code,),
)
return int(row["fee"]) if row and row["fee"] is not None else None
def expedited_fee_cents(self) -> Optional[int]:
"""Return expedited fee in cents, normalized from the seeded value.
`state_filing_fees.expedited_fee` was seeded inconsistently for
some states it was stored as dollars × 10000 (the DB convention
used by `expedited_fee_cents` in formation_orders is cents), so
we divide by 100 if the stored value is suspiciously large. See
the same normalization in `scripts.workers.crypto_offramp.sizer`.
"""
row = _query_one(
"SELECT expedited_fee FROM state_filing_fees WHERE state_code = %s",
(self.code,),
)
if not row or row["expedited_fee"] is None:
return None
raw = int(row["expedited_fee"])
return raw // 100 if raw > 50000 else raw
def requires_publication(self) -> bool:
"""Some states (NY, AZ, NE) require newspaper publication after filing."""
row = _query_one(
"SELECT publication_required FROM state_filing_fees WHERE state_code = %s",
(self.code,),
)
return bool(row and row.get("publication_required"))
# ────────────────────────────────────────────────────────────────── #
# Adapter bridge — return the legacy StatePortal for this code.
# ────────────────────────────────────────────────────────────────── #
def adapter(self):
"""Dynamically import the state's StatePortal adapter.
Bridges to `scripts.formation.states.{code}.adapter`. Raises
`ImportError` if the adapter hasn't been written yet (not every
jurisdiction has a filer).
"""
from scripts.formation.states import get_adapter
return get_adapter(self.code)
def has_adapter(self) -> bool:
"""Whether a Playwright adapter is implemented for this code."""
try:
from scripts.formation.states import get_adapter
get_adapter(self.code)
return True
except Exception:
return False
# ────────────────────────────────────────────────────────────────────── #
# Internal helpers
# ────────────────────────────────────────────────────────────────────── #
_FOREIGN_QUAL_FEE_COL = {
"llc": "foreign_llc_fee",
"pllc": "foreign_llc_fee",
"corporation": "foreign_corp_fee",
"c_corp": "foreign_corp_fee",
"s_corp": "foreign_corp_fee",
"pc": "foreign_corp_fee",
"nonprofit": "foreign_corp_fee",
}
_FORMATION_FEE_COL = {
"llc": "llc_formation_fee",
"pllc": "llc_formation_fee",
"corporation": "corp_formation_fee",
"c_corp": "corp_formation_fee",
"s_corp": "corp_formation_fee",
"pc": "corp_formation_fee",
"nonprofit": "corp_formation_fee",
}
def _normalize_entity_type(et: str) -> str:
"""Collapse variants to the canonical key used in the fee-column maps."""
return (et or "").strip().lower().replace("-", "_")
def _connect():
return psycopg2.connect(os.environ.get("DATABASE_URL", ""))
def _query_one(sql: str, params: tuple) -> Optional[dict]:
conn = _connect()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
def _row_to_config(row: dict) -> JurisdictionConfig:
entity_types_raw = row.get("entity_types_json") or []
entity_types = [
EntityTypeSpec(code=e["code"], label=e["label"])
for e in entity_types_raw
if isinstance(e, dict) and "code" in e
]
return JurisdictionConfig(
code=row["code"],
name=row["name"],
country=row["country"],
kind=row["kind"],
currency=row["currency"],
timezone=row.get("timezone"),
portal_name=row.get("portal_name"),
portal_url=row.get("portal_url"),
portal_login_required=bool(row.get("portal_login_required", False)),
entity_types=entity_types,
supports_foreign_qualification=bool(
row.get("supports_foreign_qualification", True),
),
foreign_qual_portal_url=row.get("foreign_qual_portal_url"),
foreign_qual_requires_coa=bool(row.get("foreign_qual_requires_coa", True)),
nwra_foreign_qual_wholesale_cents=row.get("nwra_foreign_qual_wholesale_cents"),
notes=row.get("notes"),
)
# ────────────────────────────────────────────────────────────────────── #
# Public API
# ────────────────────────────────────────────────────────────────────── #
@lru_cache(maxsize=128)
def get_jurisdiction(code: str) -> JurisdictionConfig:
"""Load the jurisdiction for `code` from the DB.
Cached safe because the table is seeded once per deploy. Flush
with `get_jurisdiction.cache_clear()` if you update a row live.
"""
code_u = (code or "").strip().upper()
if not code_u:
raise ValueError("jurisdiction code required")
row = _query_one(
"""
SELECT code, name, country, kind, currency, timezone,
portal_name, portal_url, portal_login_required,
entity_types_json,
supports_foreign_qualification,
foreign_qual_portal_url, foreign_qual_requires_coa,
nwra_foreign_qual_wholesale_cents,
notes
FROM jurisdictions
WHERE code = %s
""",
(code_u,),
)
if not row:
raise ValueError(f"Unknown jurisdiction code: {code_u}")
return _row_to_config(row)
def list_jurisdictions(
country: Optional[str] = None,
kind: Optional[str] = None,
supports_foreign_qualification: Optional[bool] = None,
) -> list[JurisdictionConfig]:
"""Return every jurisdiction, optionally filtered."""
sql = """
SELECT code, name, country, kind, currency, timezone,
portal_name, portal_url, portal_login_required,
entity_types_json,
supports_foreign_qualification,
foreign_qual_portal_url, foreign_qual_requires_coa,
nwra_foreign_qual_wholesale_cents,
notes
FROM jurisdictions
"""
conditions: list[str] = []
params: list = []
if country:
conditions.append("country = %s")
params.append(country.upper())
if kind:
conditions.append("kind = %s")
params.append(kind.lower())
if supports_foreign_qualification is not None:
conditions.append("supports_foreign_qualification = %s")
params.append(supports_foreign_qualification)
if conditions:
sql += "\n WHERE " + " AND ".join(conditions)
sql += "\n ORDER BY country, code"
conn = _connect()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, tuple(params))
return [_row_to_config(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
__all__ = [
"EntityTypeSpec",
"JurisdictionConfig",
"get_jurisdiction",
"list_jurisdictions",
]

View file

@ -0,0 +1,178 @@
"""
name_search.py Multi-state business name availability search coordinator.
Loads the appropriate state adapter and performs name availability searches.
Supports searching a single state or multiple states in parallel.
Usage:
python -m formation.name_search "My Business LLC" WY
python -m formation.name_search "My Business LLC" WY,NV,NM,TX
"""
from __future__ import annotations
import asyncio
import logging
import sys
import time
from dataclasses import asdict
from .base import NameSearchResult
from .states import get_adapter, STATES
LOG = logging.getLogger("formation.name_search")
async def search_name(name: str, state_code: str) -> NameSearchResult:
"""
Search for business name availability in a single state.
Loads the state adapter, launches a browser session, performs the search,
and returns a NameSearchResult.
Args:
name: The business name to search (e.g. "Acme Holdings LLC").
state_code: Two-letter state code (e.g. "WY").
Returns:
NameSearchResult with availability info.
"""
code = state_code.upper()
if code not in STATES:
return NameSearchResult(
available=False,
searched_name=name,
state_code=code,
raw_response=f"Unknown state code: {code}",
)
LOG.info("Searching name '%s' in %s (%s)...", name, code, STATES[code]["name"])
adapter = get_adapter(code)
try:
await adapter.start_browser(headless=True)
result = await adapter.search_name(name)
# Ensure state_code and searched_name are populated
result.state_code = result.state_code or code
result.searched_name = result.searched_name or name
return result
except Exception as exc:
LOG.error("Name search failed in %s: %s", code, exc, exc_info=True)
return NameSearchResult(
available=False,
searched_name=name,
state_code=code,
raw_response=f"Error: {exc}",
)
finally:
await adapter.close_browser()
async def search_multiple_states(
name: str,
state_codes: list[str],
) -> list[NameSearchResult]:
"""
Search for business name availability across multiple states in parallel.
Launches concurrent searches using asyncio.gather. Each state gets its own
browser instance so they don't interfere with each other.
Args:
name: The business name to search.
state_codes: List of two-letter state codes.
Returns:
List of NameSearchResult, one per state (order matches state_codes).
"""
LOG.info(
"Searching name '%s' across %d states: %s",
name,
len(state_codes),
", ".join(c.upper() for c in state_codes),
)
start = time.monotonic()
tasks = [search_name(name, code) for code in state_codes]
results = await asyncio.gather(*tasks, return_exceptions=False)
elapsed = time.monotonic() - start
available_in = [r.state_code for r in results if r.available]
unavailable_in = [r.state_code for r in results if not r.available]
LOG.info(
"Multi-state search complete in %.1fs — available: %s | unavailable: %s",
elapsed,
", ".join(available_in) or "(none)",
", ".join(unavailable_in) or "(none)",
)
return results
def _format_result(result: NameSearchResult) -> str:
"""Pretty-print a single search result for CLI output."""
status = "AVAILABLE" if result.available else "UNAVAILABLE"
lines = [
f" [{result.state_code}] {status}\"{result.searched_name}\"",
]
if result.exact_match:
lines.append(f" Exact match found")
if result.similar_names:
lines.append(f" Similar names: {', '.join(result.similar_names[:5])}")
return "\n".join(lines)
async def _main(name: str, raw_states: str) -> int:
"""CLI entry point logic."""
# Parse comma-separated or space-separated state codes
state_codes = [s.strip().upper() for s in raw_states.replace(",", " ").split() if s.strip()]
if not state_codes:
print("Error: No state codes provided.", file=sys.stderr)
return 1
# Validate state codes
invalid = [s for s in state_codes if s not in STATES]
if invalid:
print(f"Error: Unknown state code(s): {', '.join(invalid)}", file=sys.stderr)
print(f"Valid codes: {', '.join(sorted(STATES.keys()))}", file=sys.stderr)
return 1
print(f"Searching: \"{name}\"")
print(f"States: {', '.join(state_codes)}")
print("-" * 60)
if len(state_codes) == 1:
result = await search_name(name, state_codes[0])
results = [result]
else:
results = await search_multiple_states(name, state_codes)
for r in results:
print(_format_result(r))
print("-" * 60)
available_count = sum(1 for r in results if r.available)
print(f"Available in {available_count}/{len(results)} state(s).")
return 0
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if len(sys.argv) < 3:
print("Usage: python -m formation.name_search <business_name> <state_code(s)>")
print()
print("Examples:")
print(' python -m formation.name_search "Acme Holdings LLC" WY')
print(' python -m formation.name_search "Acme Holdings LLC" WY,NV,NM,TX')
sys.exit(1)
business_name = sys.argv[1]
states_arg = " ".join(sys.argv[2:]) # Allow "WY NV" or "WY,NV"
exit_code = asyncio.run(_main(business_name, states_arg))
sys.exit(exit_code)

View file

@ -0,0 +1,640 @@
"""
operating_agreement.py Generate LLC Operating Agreement documents.
Uses a template-based approach with python-docx to produce a professional
operating agreement in both .docx and .pdf formats.
DISCLAIMER: This operating agreement template is for informational purposes
only and does not constitute legal advice. Consult a licensed attorney for
legal guidance specific to your situation.
Output:
/tmp/formations/{order_id}/operating-agreement.docx
/tmp/formations/{order_id}/operating-agreement.pdf
Usage:
# Programmatic
from formation.operating_agreement import generate_operating_agreement
docx_path, pdf_path = generate_operating_agreement(order)
# CLI
python -m formation.operating_agreement <order_id>
"""
from __future__ import annotations
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from .base import EntityType, FormationOrder, Member
from .states import STATES
LOG = logging.getLogger("formation.oa")
DISCLAIMER = (
"DISCLAIMER: This operating agreement template is for informational purposes "
"only and does not constitute legal advice. Every business situation is unique. "
"You should consult with a licensed attorney in your jurisdiction before relying "
"on this document for legal purposes."
)
# ---------------------------------------------------------------------------
# Document generation
# ---------------------------------------------------------------------------
def generate_operating_agreement(order: FormationOrder) -> tuple[str, str]:
"""
Generate an LLC Operating Agreement in .docx and .pdf formats.
Args:
order: FormationOrder with entity, member, and management details.
Returns:
Tuple of (docx_path, pdf_path).
"""
output_dir = Path(f"/tmp/formations/{order.order_id}")
output_dir.mkdir(parents=True, exist_ok=True)
docx_path = output_dir / "operating-agreement.docx"
pdf_path = output_dir / "operating-agreement.pdf"
state_name = STATES.get(order.state_code.upper(), {}).get("name", order.state_code)
formation_date = order.filed_at or order.effective_date or datetime.now().strftime("%B %d, %Y")
# Parse formation_date if it's ISO format
if "T" in formation_date or (len(formation_date) == 10 and "-" in formation_date):
try:
dt = datetime.fromisoformat(formation_date.replace("Z", "+00:00"))
formation_date = dt.strftime("%B %d, %Y")
except ValueError:
pass
management_display = (
"Member-Managed" if order.management_type == "member_managed" else "Manager-Managed"
)
doc = Document()
# -- Document styles --
style = doc.styles["Normal"]
font = style.font
font.name = "Times New Roman"
font.size = Pt(11)
style.paragraph_format.space_after = Pt(6)
style.paragraph_format.line_spacing = 1.15
# -- Disclaimer --
disclaimer_para = doc.add_paragraph()
disclaimer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = disclaimer_para.add_run(DISCLAIMER)
run.font.size = Pt(9)
run.font.italic = True
run.font.color.rgb = RGBColor(128, 128, 128)
doc.add_paragraph() # spacer
# -- Title --
title = doc.add_heading("OPERATING AGREEMENT", level=0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
subtitle = doc.add_heading(f"OF\n{order.entity_name.upper()}", level=1)
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
type_para = doc.add_paragraph()
type_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = type_para.add_run(f"A {state_name} Limited Liability Company")
run.font.size = Pt(12)
date_para = doc.add_paragraph()
date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = date_para.add_run(f"Effective Date: {formation_date}")
run.font.size = Pt(11)
run.font.italic = True
doc.add_paragraph() # spacer
# ======================================================================
# ARTICLE I — FORMATION
# ======================================================================
_add_article(doc, "I", "FORMATION")
_add_section(doc, "1.1", "Formation", (
f"The Members hereby form a Limited Liability Company (the \"Company\") "
f"under the laws of the State of {state_name}, pursuant to the "
f"{state_name} Limited Liability Company Act (the \"Act\")."
))
_add_section(doc, "1.2", "Name", (
f"The name of the Company shall be {order.entity_name} "
f"(the \"Company\")."
))
_add_section(doc, "1.3", "Principal Office", (
f"The principal office of the Company shall be located at "
f"{order.principal_address or '[ADDRESS]'}, "
f"{order.principal_city or '[CITY]'}, "
f"{order.principal_state or '[STATE]'} "
f"{order.principal_zip or '[ZIP]'}. "
f"The Company may change its principal office upon written notice to all Members."
))
_add_section(doc, "1.4", "Registered Agent", (
f"The registered agent for service of process shall be "
f"{order.registered_agent_name or '[REGISTERED AGENT]'}, "
f"located at {order.registered_agent_address or '[REGISTERED AGENT ADDRESS]'}."
))
_add_section(doc, "1.5", "Purpose", (
f"The purpose of the Company is to engage in {order.purpose}. "
f"The Company may engage in any other lawful activity permitted under "
f"the Act and the laws of the State of {state_name}."
))
_add_section(doc, "1.6", "Duration", (
"The Company shall have perpetual existence unless dissolved in accordance "
"with this Agreement or as required by law."
))
_add_section(doc, "1.7", "Fiscal Year", (
f"The fiscal year of the Company shall end on {order.fiscal_year_end or 'December 31'} "
f"of each year."
))
# ======================================================================
# ARTICLE II — MEMBERS
# ======================================================================
_add_article(doc, "II", "MEMBERS")
_add_section(doc, "2.1", "Members", (
"The names, addresses, and ownership interests of the Members are as follows:"
))
# Members table
if order.members:
table = doc.add_table(rows=1, cols=4)
table.style = "Table Grid"
table.alignment = WD_TABLE_ALIGNMENT.CENTER
# Header row
hdr = table.rows[0].cells
for i, text in enumerate(["Member Name", "Address", "Ownership %", "Title"]):
hdr[i].text = text
for paragraph in hdr[i].paragraphs:
for run in paragraph.runs:
run.font.bold = True
run.font.size = Pt(10)
# Member rows
for member in order.members:
row = table.add_row().cells
row[0].text = member.name
addr = f"{member.address}, {member.city}, {member.state} {member.zip_code}"
row[1].text = addr.strip(", ")
row[2].text = f"{member.ownership_pct:.1f}%"
row[3].text = member.title
for cell in row:
for paragraph in cell.paragraphs:
for run in paragraph.runs:
run.font.size = Pt(10)
# Set column widths
for row in table.rows:
row.cells[0].width = Inches(1.8)
row.cells[1].width = Inches(2.5)
row.cells[2].width = Inches(1.0)
row.cells[3].width = Inches(1.0)
doc.add_paragraph() # spacer after table
_add_section(doc, "2.2", "Admission of New Members", (
"New Members may be admitted to the Company only with the unanimous written "
"consent of all existing Members. Any new Member shall execute a counterpart "
"of this Agreement and shall be bound by all terms herein."
))
# ======================================================================
# ARTICLE III — MANAGEMENT
# ======================================================================
_add_article(doc, "III", "MANAGEMENT")
if order.management_type == "member_managed":
_add_section(doc, "3.1", "Member-Managed", (
"The Company shall be managed by its Members. Each Member shall have "
"the right to participate in the management of the Company and shall "
"have the authority to bind the Company in the ordinary course of business."
))
_add_section(doc, "3.2", "Voting Rights", (
"Each Member shall have voting rights in proportion to their ownership "
"interest. Unless otherwise specified in this Agreement, decisions "
"shall be made by a majority vote of the membership interests."
))
_add_section(doc, "3.3", "Major Decisions", (
"The following actions shall require the unanimous consent of all Members: "
"(a) sale of all or substantially all Company assets; "
"(b) merger or consolidation of the Company; "
"(c) any amendment to this Operating Agreement; "
"(d) admission of a new Member; "
"(e) any act that would make it impossible to carry on the ordinary business "
"of the Company."
))
else:
_add_section(doc, "3.1", "Manager-Managed", (
"The Company shall be managed by one or more Managers appointed by the "
"Members. The Manager(s) shall have full authority to manage the business "
"and affairs of the Company, including the authority to bind the Company "
"in the ordinary course of business."
))
_add_section(doc, "3.2", "Appointment of Managers", (
"Managers shall be appointed by a majority vote of the membership interests. "
"A Manager may be removed at any time, with or without cause, by a majority "
"vote of the membership interests."
))
_add_section(doc, "3.3", "Manager Authority", (
"The Manager(s) shall manage the day-to-day operations of the Company. "
"Members who are not Managers shall not participate in the management "
"or control of the Company's business and shall have no authority to "
"bind the Company."
))
_add_section(doc, "3.4", "Major Decisions", (
"The following actions shall require the unanimous consent of all Members, "
"regardless of management structure: "
"(a) sale of all or substantially all Company assets; "
"(b) merger or consolidation of the Company; "
"(c) any amendment to this Operating Agreement; "
"(d) admission of a new Member."
))
# ======================================================================
# ARTICLE IV — CAPITAL CONTRIBUTIONS
# ======================================================================
_add_article(doc, "IV", "CAPITAL CONTRIBUTIONS")
_add_section(doc, "4.1", "Initial Contributions", (
"Each Member shall make an initial capital contribution to the Company "
"in cash or property as agreed upon by the Members. The value of each "
"Member's initial contribution shall be recorded in the Company's books."
))
_add_section(doc, "4.2", "Additional Contributions", (
"No Member shall be required to make additional capital contributions "
"to the Company without the unanimous consent of all Members. Any "
"additional contributions shall be made in proportion to the Members' "
"ownership interests unless otherwise agreed."
))
_add_section(doc, "4.3", "Capital Accounts", (
"The Company shall maintain a separate capital account for each Member. "
"Each Member's capital account shall be credited with the Member's "
"contributions and share of profits, and debited with the Member's "
"distributions and share of losses."
))
_add_section(doc, "4.4", "No Interest on Capital", (
"No Member shall receive interest on their capital contribution or "
"capital account balance."
))
# ======================================================================
# ARTICLE V — DISTRIBUTIONS
# ======================================================================
_add_article(doc, "V", "DISTRIBUTIONS")
_add_section(doc, "5.1", "Distributions", (
"Distributions of the Company's net cash flow shall be made to the Members "
"pro rata in accordance with their respective ownership percentages, at such "
"times and in such amounts as determined by the Members (or Manager(s), if "
"manager-managed)."
))
_add_section(doc, "5.2", "Tax Distributions", (
"The Company shall, at a minimum, distribute to each Member an amount "
"sufficient to cover each Member's estimated tax liability arising from "
"the Company's income allocated to such Member, calculated at the highest "
"applicable marginal tax rate."
))
_add_section(doc, "5.3", "Limitation on Distributions", (
"No distribution shall be made if, after giving effect to the distribution, "
"the Company would not be able to pay its debts as they become due in the "
"ordinary course of business."
))
# ======================================================================
# ARTICLE VI — MEETINGS AND VOTING
# ======================================================================
_add_article(doc, "VI", "MEETINGS AND VOTING")
_add_section(doc, "6.1", "Meetings", (
"The Members shall hold an annual meeting at such time and place as "
"determined by the Members. Special meetings may be called by any Member "
"upon not less than ten (10) days' written notice to all other Members."
))
_add_section(doc, "6.2", "Quorum", (
"A quorum for any meeting of Members shall consist of Members holding "
"more than fifty percent (50%) of the total ownership interests."
))
_add_section(doc, "6.3", "Action Without Meeting", (
"Any action that may be taken at a meeting of the Members may be taken "
"without a meeting if the action is consented to in writing by Members "
"holding sufficient ownership interests to authorize such action at a meeting."
))
_add_section(doc, "6.4", "Voting", (
"Each Member shall be entitled to vote in proportion to their ownership "
"interest. Except as otherwise provided in this Agreement, decisions "
"shall be made by a majority vote of the total ownership interests."
))
# ======================================================================
# ARTICLE VII — TRANSFER OF MEMBERSHIP INTERESTS
# ======================================================================
_add_article(doc, "VII", "TRANSFER OF MEMBERSHIP INTERESTS")
_add_section(doc, "7.1", "Restrictions on Transfer", (
"No Member may sell, assign, pledge, or otherwise transfer all or any "
"portion of their membership interest without the prior written consent "
"of all other Members."
))
_add_section(doc, "7.2", "Right of First Refusal", (
"Before any Member may transfer their interest to a non-Member, the "
"transferring Member shall first offer the interest to the remaining "
"Members, pro rata, at the same price and on the same terms as the "
"proposed transfer. The remaining Members shall have thirty (30) days "
"to accept or decline the offer."
))
_add_section(doc, "7.3", "Permitted Transfers", (
"Notwithstanding the foregoing, a Member may transfer their interest "
"to a revocable trust established by such Member for estate planning "
"purposes, provided that the transferring Member remains the trustee "
"or retains control of the trust."
))
# ======================================================================
# ARTICLE VIII — DISSOLUTION
# ======================================================================
_add_article(doc, "VIII", "DISSOLUTION")
_add_section(doc, "8.1", "Events of Dissolution", (
"The Company shall be dissolved upon the occurrence of any of the following: "
"(a) the unanimous written agreement of all Members; "
"(b) entry of a decree of judicial dissolution; "
"(c) any event that makes it unlawful for the Company to continue its business; "
f"(d) as otherwise required by the laws of the State of {state_name}."
))
_add_section(doc, "8.2", "Winding Up", (
"Upon dissolution, the Company's affairs shall be wound up. The assets "
"shall be liquidated and the proceeds applied in the following order: "
"(a) payment of debts and liabilities to creditors; "
"(b) payment of debts and liabilities to Members; "
"(c) distribution to Members in accordance with their positive capital "
"account balances."
))
# ======================================================================
# ARTICLE IX — MISCELLANEOUS
# ======================================================================
_add_article(doc, "IX", "MISCELLANEOUS")
_add_section(doc, "9.1", "Governing Law", (
f"This Agreement shall be governed by and construed in accordance with "
f"the laws of the State of {state_name}, without regard to its conflict "
f"of laws principles."
))
_add_section(doc, "9.2", "Amendments", (
"This Agreement may be amended only by a written instrument signed by "
"all Members."
))
_add_section(doc, "9.3", "Severability", (
"If any provision of this Agreement is held to be invalid, illegal, or "
"unenforceable, the remaining provisions shall continue in full force "
"and effect."
))
_add_section(doc, "9.4", "Entire Agreement", (
"This Agreement constitutes the entire agreement among the Members with "
"respect to the subject matter hereof and supersedes all prior agreements, "
"understandings, negotiations, and discussions."
))
_add_section(doc, "9.5", "Binding Effect", (
"This Agreement shall be binding upon and inure to the benefit of the "
"Members and their respective heirs, executors, administrators, "
"successors, and permitted assigns."
))
_add_section(doc, "9.6", "Indemnification", (
"The Company shall indemnify and hold harmless each Member and Manager "
"from and against any and all claims, liabilities, damages, and expenses "
"(including reasonable attorneys' fees) arising out of or relating to "
"the Company's business, except to the extent caused by such person's "
"gross negligence or willful misconduct."
))
# ======================================================================
# ARTICLE X — SIGNATURE BLOCK
# ======================================================================
_add_article(doc, "X", "EXECUTION")
doc.add_paragraph(
"IN WITNESS WHEREOF, the undersigned Members have executed this "
f"Operating Agreement as of {formation_date}."
)
doc.add_paragraph() # spacer
# Signature lines for each member
for member in order.members:
sig_block = doc.add_paragraph()
sig_block.add_run("\n")
sig_block.add_run("_" * 50)
sig_block.add_run("\n")
name_run = sig_block.add_run(member.name)
name_run.font.bold = True
sig_block.add_run(f"\n{member.title}")
sig_block.add_run(f"\nOwnership: {member.ownership_pct:.1f}%")
sig_block.add_run("\nDate: _________________")
doc.add_paragraph() # spacer between signature blocks
# If no members listed, add a generic signature block
if not order.members:
sig_block = doc.add_paragraph()
sig_block.add_run("\n")
sig_block.add_run("_" * 50)
sig_block.add_run("\nMember Name: _________________________")
sig_block.add_run("\nTitle: _______________________________")
sig_block.add_run("\nDate: ________________________________")
# -- Save .docx --
doc.save(str(docx_path))
LOG.info("Operating agreement .docx saved: %s", docx_path)
# -- Convert to PDF --
try:
from docx2pdf import convert
convert(str(docx_path), str(pdf_path))
LOG.info("Operating agreement .pdf saved: %s", pdf_path)
except ImportError:
LOG.warning(
"docx2pdf not available — PDF conversion skipped. "
"Install with: pip install docx2pdf"
)
# Attempt LibreOffice fallback
try:
import subprocess
result = subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to", "pdf",
"--outdir", str(output_dir),
str(docx_path),
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0 and pdf_path.exists():
LOG.info("Operating agreement .pdf saved (LibreOffice): %s", pdf_path)
else:
LOG.warning("LibreOffice conversion failed: %s", result.stderr)
pdf_path = Path("") # No PDF available
except FileNotFoundError:
LOG.warning(
"Neither docx2pdf nor LibreOffice available for PDF conversion."
)
pdf_path = Path("")
except Exception as exc:
LOG.error("PDF conversion failed: %s", exc)
pdf_path = Path("")
return str(docx_path), str(pdf_path)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _add_article(doc: Document, number: str, title: str):
"""Add an article heading."""
heading = doc.add_heading(f"ARTICLE {number}{title}", level=2)
heading.alignment = WD_ALIGN_PARAGRAPH.LEFT
def _add_section(doc: Document, number: str, title: str, text: str):
"""Add a numbered section with bold title and body text."""
para = doc.add_paragraph()
run_num = para.add_run(f"Section {number}. {title}. ")
run_num.font.bold = True
para.add_run(text)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
"""Generate an operating agreement from a formation order in the database."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
if len(sys.argv) < 2:
print("Usage: python -m formation.operating_agreement <order_id>")
print()
print("Generates an LLC operating agreement (.docx and .pdf)")
print("from the formation order data in the database.")
sys.exit(1)
order_id = sys.argv[1]
database_url = os.environ.get("DATABASE_URL", "")
if not database_url:
print("Error: DATABASE_URL not set.", file=sys.stderr)
sys.exit(1)
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(database_url)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
row = cur.fetchone()
finally:
conn.close()
if not row:
print(f"Error: Order {order_id} not found.", file=sys.stderr)
sys.exit(1)
# Build FormationOrder
members_raw = row.get("members")
if isinstance(members_raw, str):
members_raw = json.loads(members_raw)
elif members_raw is None:
members_raw = []
members = [
Member(
name=m.get("name", ""),
address=m.get("address", ""),
city=m.get("city", ""),
state=m.get("state", ""),
zip_code=m.get("zip_code", ""),
title=m.get("title", "Member"),
ownership_pct=float(m.get("ownership_pct", 0)),
is_organizer=bool(m.get("is_organizer", False)),
)
for m in members_raw
]
try:
entity_type = EntityType(row.get("entity_type", "llc"))
except ValueError:
entity_type = EntityType.LLC
order = FormationOrder(
order_id=str(row["order_id"]),
state_code=row.get("state_code", ""),
entity_type=entity_type,
entity_name=row.get("entity_name", ""),
management_type=row.get("management_type", "member_managed"),
purpose=row.get("purpose", "Any lawful business activity"),
members=members,
registered_agent_name=row.get("registered_agent_name", "Northwest Registered Agent"),
registered_agent_address=row.get("registered_agent_address", ""),
principal_address=row.get("principal_address", ""),
principal_city=row.get("principal_city", ""),
principal_state=row.get("principal_state", ""),
principal_zip=row.get("principal_zip", ""),
fiscal_year_end=row.get("fiscal_year_end", "12/31"),
effective_date=row.get("effective_date", "") or "",
filed_at=row.get("filed_at", "") or "",
)
docx_path, pdf_path = generate_operating_agreement(order)
print(f"Generated operating agreement:")
print(f" DOCX: {docx_path}")
print(f" PDF: {pdf_path or '(not available — install docx2pdf or libreoffice)'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,232 @@
"""
Portal Schedule business hours awareness for state/provincial portals.
Some portals restrict filing hours. Attempting automation outside these windows
results in portal-unavailable errors, which are hard to distinguish from real failures.
This module provides a consistent interface to check availability and compute
the next open window so the job server can defer rather than fail.
Known restricted portals:
BC Corporate Online: Mon-Sat 06:00-22:00 PT, Sun 13:00-22:00 PT
IRS EIN Assistant: Mon-Fri 07:00-22:00 ET
All others: 24/7 (None schedule = always available)
Usage:
schedule = PortalSchedule.from_config(config["portal_schedule"])
available, next_open = schedule.is_available()
if not available:
job.defer_until(next_open)
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from zoneinfo import ZoneInfo
LOG = logging.getLogger("formation.portal_schedule")
# Day index: 0=Monday ... 6=Sunday (matches datetime.weekday())
DAY_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
@dataclass
class DayWindow:
"""Open/close hours for a single day of the week. None = closed all day."""
open_hour: int # 0-23 inclusive
close_hour: int # 0-23 inclusive (exclusive end — portal closes AT this hour)
# Pre-built schedule configs for known restricted portals
BC_CORPORATE_ONLINE_SCHEDULE = {
"timezone": "America/Vancouver",
"jurisdiction": "BC",
"closed_holidays": True,
"hours": {
"mon": [6, 22],
"tue": [6, 22],
"wed": [6, 22],
"thu": [6, 22],
"fri": [6, 22],
"sat": [6, 22],
"sun": [13, 22],
},
}
IRS_EIN_SCHEDULE = {
"timezone": "America/New_York",
"jurisdiction": "IRS",
"closed_holidays": True,
"hours": {
"mon": [7, 22],
"tue": [7, 22],
"wed": [7, 22],
"thu": [7, 22],
"fri": [7, 22],
"sat": None, # Closed
"sun": None, # Closed
},
}
# Standard US state SOS portal — Mon-Fri 7am-11pm ET, closed weekends & federal holidays
US_STATE_SOS_SCHEDULE = {
"timezone": "America/New_York",
"jurisdiction": "US",
"closed_holidays": True,
"hours": {
"mon": [7, 23],
"tue": [7, 23],
"wed": [7, 23],
"thu": [7, 23],
"fri": [7, 23],
"sat": None,
"sun": None,
},
}
@dataclass
class PortalSchedule:
"""
Defines the business hours of a filing portal.
Attributes:
timezone: IANA timezone string (e.g. 'America/Vancouver')
hours: Dict mapping day name to [open, close] hours or None if closed.
jurisdiction: Holiday jurisdiction code: 'US', 'CA', 'BC', 'IRS', or None (no holiday check).
closed_holidays: If True (default), treat holidays as closed days even if hours are defined.
"""
timezone: str
hours: dict[str, Optional[list[int]]] # day -> [open_hour, close_hour] | None
jurisdiction: Optional[str] = None # 'US', 'CA', 'BC', 'IRS'
closed_holidays: bool = True
@classmethod
def always_open(cls) -> "PortalSchedule":
"""Return a schedule that is always available (24/7 portals, no holidays)."""
return cls(
timezone="UTC",
hours={d: [0, 24] for d in DAY_NAMES},
jurisdiction=None,
closed_holidays=False,
)
@classmethod
def from_config(cls, config: Optional[dict]) -> "PortalSchedule":
"""
Build a PortalSchedule from a config dict.
If config is None, returns an always-open schedule (24/7 portal).
Config keys:
timezone: IANA timezone (default: 'UTC')
hours: day -> [open, close] | None
jurisdiction: 'US' | 'CA' | 'BC' | 'IRS' | None
closed_holidays: bool (default True)
"""
if config is None:
return cls.always_open()
return cls(
timezone=config.get("timezone", "UTC"),
hours=config.get("hours", {d: [0, 24] for d in DAY_NAMES}),
jurisdiction=config.get("jurisdiction"),
closed_holidays=config.get("closed_holidays", True),
)
def _is_holiday_today(self, local_date) -> bool:
"""Check if the local date is a holiday in our jurisdiction."""
if not self.closed_holidays or not self.jurisdiction:
return False
try:
from scripts.formation.holidays import is_holiday as _is_holiday
return _is_holiday(local_date, jurisdiction=self.jurisdiction)
except ImportError:
LOG.warning("holidays module not available — skipping holiday check")
return False
def _holiday_name(self, local_date) -> Optional[str]:
"""Get the name of the holiday if it is one."""
if not self.closed_holidays or not self.jurisdiction:
return None
try:
from scripts.formation.holidays import holiday_name as _holiday_name
return _holiday_name(local_date, jurisdiction=self.jurisdiction)
except ImportError:
return None
def is_available(self, at: Optional[datetime] = None) -> tuple[bool, Optional[datetime]]:
"""
Check if the portal is currently available.
Checks:
1. Holiday calendar (jurisdiction-aware)
2. Day-of-week business hours
Args:
at: datetime to check (defaults to now). Should be timezone-naive UTC or tz-aware.
Returns:
(available: bool, next_open: datetime | None)
next_open is UTC datetime of the next opening time (None if currently open).
"""
tz = ZoneInfo(self.timezone)
now_utc = at or datetime.utcnow().replace(tzinfo=ZoneInfo("UTC"))
if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=ZoneInfo("UTC"))
now_local = now_utc.astimezone(tz)
# Holiday check — closed all day
if self._is_holiday_today(now_local.date()):
hname = self._holiday_name(now_local.date()) or "holiday"
LOG.info(f"Portal closed for {hname} ({now_local.date()})")
next_open = self._next_open_after(now_local, tz)
return False, next_open
day_name = DAY_NAMES[now_local.weekday()]
window = self.hours.get(day_name)
if window is not None:
open_h, close_h = window[0], window[1]
if open_h <= now_local.hour < close_h:
return True, None # Currently open
# Not currently available — find next open window
next_open = self._next_open_after(now_local, tz)
return False, next_open
def _next_open_after(self, now_local: datetime, tz: ZoneInfo) -> datetime:
"""Find the next datetime (in UTC) when the portal opens, skipping holidays."""
# Search up to 14 days ahead (handles multi-day holiday stretches like Christmas week)
candidate = now_local.replace(minute=0, second=0, microsecond=0)
for _ in range(14 * 24): # hourly steps, 14 days max
candidate += timedelta(hours=1)
# Skip holidays
if self._is_holiday_today(candidate.date()):
continue
day_name = DAY_NAMES[candidate.weekday()]
window = self.hours.get(day_name)
if window is not None:
open_h, close_h = window[0], window[1]
if open_h <= candidate.hour < close_h:
# Add small random offset (0-5 min) to avoid thundering herd
jitter = timedelta(seconds=random.randint(0, 300))
return candidate.astimezone(ZoneInfo("UTC")) + jitter
# Fallback: 24 hours from now (should never hit this)
LOG.warning("Could not find next open window within 14 days — deferring 24h")
return (now_local + timedelta(hours=24)).astimezone(ZoneInfo("UTC"))
def minutes_until_open(self, at: Optional[datetime] = None) -> Optional[int]:
"""Return minutes until next open, or None if currently open."""
available, next_open = self.is_available(at)
if available or next_open is None:
return None
now_utc = (at or datetime.utcnow()).replace(tzinfo=ZoneInfo("UTC"))
if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=ZoneInfo("UTC"))
delta = next_open - now_utc
return max(0, int(delta.total_seconds() / 60))

View file

@ -0,0 +1,96 @@
"""
State adapter registry.
Maps 2-letter state codes to their adapter modules.
Each state directory contains:
- config.py Portal URLs, NW RA address, selectors, fees
- adapter.py StatePortal subclass with Playwright automation
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from scripts.formation.base import StatePortal
# State metadata for the registry
STATES = {
"AL": {"name": "Alabama", "search_method": "playwright"},
"AK": {"name": "Alaska", "search_method": "socrata"},
"AZ": {"name": "Arizona", "search_method": "playwright"},
"AR": {"name": "Arkansas", "search_method": "playwright"},
"CA": {"name": "California", "search_method": "playwright"},
"CO": {"name": "Colorado", "search_method": "socrata_api"},
"CT": {"name": "Connecticut", "search_method": "socrata"},
"DE": {"name": "Delaware", "search_method": "playwright"},
"FL": {"name": "Florida", "search_method": "sftp_bulk"},
"GA": {"name": "Georgia", "search_method": "playwright"},
"HI": {"name": "Hawaii", "search_method": "playwright"},
"ID": {"name": "Idaho", "search_method": "playwright"},
"IL": {"name": "Illinois", "search_method": "socrata"},
"IN": {"name": "Indiana", "search_method": "playwright"},
"IA": {"name": "Iowa", "search_method": "socrata"},
"KS": {"name": "Kansas", "search_method": "playwright"},
"KY": {"name": "Kentucky", "search_method": "playwright"},
"LA": {"name": "Louisiana", "search_method": "playwright"},
"ME": {"name": "Maine", "search_method": "playwright"},
"MD": {"name": "Maryland", "search_method": "playwright"},
"MA": {"name": "Massachusetts", "search_method": "playwright"},
"MI": {"name": "Michigan", "search_method": "socrata"},
"MN": {"name": "Minnesota", "search_method": "playwright"},
"MS": {"name": "Mississippi", "search_method": "playwright"},
"MO": {"name": "Missouri", "search_method": "playwright"},
"MT": {"name": "Montana", "search_method": "playwright"},
"NE": {"name": "Nebraska", "search_method": "playwright"},
"NV": {"name": "Nevada", "search_method": "playwright"},
"NH": {"name": "New Hampshire", "search_method": "playwright"},
"NJ": {"name": "New Jersey", "search_method": "playwright"},
"NM": {"name": "New Mexico", "search_method": "playwright"},
"NY": {"name": "New York", "search_method": "socrata"},
"NC": {"name": "North Carolina", "search_method": "playwright"},
"ND": {"name": "North Dakota", "search_method": "playwright"},
"OH": {"name": "Ohio", "search_method": "playwright"},
"OK": {"name": "Oklahoma", "search_method": "playwright"},
"OR": {"name": "Oregon", "search_method": "socrata"},
"PA": {"name": "Pennsylvania", "search_method": "socrata"},
"RI": {"name": "Rhode Island", "search_method": "playwright"},
"SC": {"name": "South Carolina", "search_method": "playwright"},
"SD": {"name": "South Dakota", "search_method": "playwright"},
"TN": {"name": "Tennessee", "search_method": "playwright"},
"TX": {"name": "Texas", "search_method": "playwright"},
"UT": {"name": "Utah", "search_method": "playwright"},
"VT": {"name": "Vermont", "search_method": "socrata"},
"VA": {"name": "Virginia", "search_method": "playwright"},
"WA": {"name": "Washington", "search_method": "socrata"},
"WV": {"name": "West Virginia", "search_method": "playwright"},
"WI": {"name": "Wisconsin", "search_method": "playwright"},
"WY": {"name": "Wyoming", "search_method": "playwright"},
"DC": {"name": "District of Columbia", "search_method": "playwright"},
# Canadian provinces
"BC": {"name": "British Columbia", "search_method": "playwright"},
"ON": {"name": "Ontario", "search_method": "playwright"},
}
def get_adapter(state_code: str) -> "StatePortal":
"""Dynamically import and return the adapter for a state."""
code = state_code.upper()
if code not in STATES:
raise ValueError(f"Unknown state code: {code}")
module_name = f".{code.lower()}.adapter"
import importlib
mod = importlib.import_module(module_name, package=__name__)
return mod.adapter()
def get_config(state_code: str) -> dict:
"""Return the config dict for a state."""
code = state_code.upper()
if code not in STATES:
raise ValueError(f"Unknown state code: {code}")
module_name = f".{code.lower()}.config"
import importlib
mod = importlib.import_module(module_name, package=__name__)
return mod.CONFIG

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,118 @@
"""Alaska — CBPL portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class AKPortal(StatePortal):
STATE_CODE = "AK"
STATE_NAME = "Alaska"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Alaska business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Alaska."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Alaska-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Alaska",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Alaska."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Alaska",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> AKPortal:
return AKPortal()

View file

@ -0,0 +1,49 @@
"""Alaska — Corporations, Business and Professional Licensing portal configuration."""
CONFIG = {
"state_code": "AK",
"state_name": "Alaska",
"sos_name": "Alaska Division of Corporations, Business and Professional Licensing",
"portal_name": "Alaska CBPL Entity Search",
"portal_url": "https://commerce.alaska.gov",
"name_search_url": "https://commerce.alaska.gov/cbp/main/search/entities",
"filing_url": "https://commerce.alaska.gov/cbp/main/search/entities",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "3000 A St Ste 200",
"nwra_city": "Anchorage",
"nwra_state": "AK",
"nwra_zip": "99503",
# State fees (cents)
"llc_formation_fee": 25000,
"corp_formation_fee": 25000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,118 @@
"""Alabama — SOS portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class ALPortal(StatePortal):
STATE_CODE = "AL"
STATE_NAME = "Alabama"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Alabama business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Alabama."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Alabama-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Alabama",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Alabama."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Alabama",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> ALPortal:
return ALPortal()

View file

@ -0,0 +1,49 @@
"""Alabama — Secretary of State portal configuration."""
CONFIG = {
"state_code": "AL",
"state_name": "Alabama",
"sos_name": "Alabama Secretary of State",
"portal_name": "Alabama Business Entity Records",
"portal_url": "https://sos.alabama.gov",
"name_search_url": "https://sos.alabama.gov/government-records/business-entity-records",
"filing_url": "https://sos.alabama.gov/government-records/business-entity-records",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "100 Centerview Dr Ste 115",
"nwra_city": "Birmingham",
"nwra_state": "AL",
"nwra_zip": "35216",
# State fees (cents)
"llc_formation_fee": 20000,
"corp_formation_fee": 20000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,118 @@
"""Arkansas — SOS portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class ARPortal(StatePortal):
STATE_CODE = "AR"
STATE_NAME = "Arkansas"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Arkansas business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Arkansas."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Arkansas-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Arkansas",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Arkansas."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Arkansas",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> ARPortal:
return ARPortal()

View file

@ -0,0 +1,49 @@
"""Arkansas — Secretary of State portal configuration."""
CONFIG = {
"state_code": "AR",
"state_name": "Arkansas",
"sos_name": "Arkansas Secretary of State",
"portal_name": "Arkansas Business Entity Search",
"portal_url": "https://sos.arkansas.gov",
"name_search_url": "https://biz.sos.arkansas.gov/search",
"filing_url": "https://biz.sos.arkansas.gov/search",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "1321 Scott St",
"nwra_city": "Little Rock",
"nwra_state": "AR",
"nwra_zip": "72202",
# State fees (cents)
"llc_formation_fee": 4500,
"corp_formation_fee": 4500,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,119 @@
"""Arizona — ACC portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class AZPortal(StatePortal):
STATE_CODE = "AZ"
STATE_NAME = "Arizona"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Arizona business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Arizona."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Arizona-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
# NOTE: Arizona requires publication after formation.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Arizona",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Arizona."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Arizona",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> AZPortal:
return AZPortal()

View file

@ -0,0 +1,49 @@
"""Arizona — Arizona Corporation Commission portal configuration."""
CONFIG = {
"state_code": "AZ",
"state_name": "Arizona",
"sos_name": "Arizona Corporation Commission",
"portal_name": "ACC eCorp Entity Search",
"portal_url": "https://azcc.gov",
"name_search_url": "https://ecorp.azcc.gov/EntitySearch/Index",
"filing_url": "https://ecorp.azcc.gov/EntitySearch/Index",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "8700 E Vista Bonita Dr Ste 268",
"nwra_city": "Scottsdale",
"nwra_state": "AZ",
"nwra_zip": "85255",
# State fees (cents)
"llc_formation_fee": 5000,
"corp_formation_fee": 6000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "Publication required. After formation, Articles of Organization must be published in an approved newspaper within 60 days.",
}

View file

@ -0,0 +1,4 @@
from .config import CONFIG
from .adapter import BCPortal
__all__ = ["CONFIG", "BCPortal"]

View file

@ -0,0 +1,977 @@
"""
British Columbia Corporate Online / BC Registry adapter.
Automates:
1. Anytime Mailbox setup (BC registered office) via anytimemailbox.com
2. Name search & reservation via bcregistrynames.gov.bc.ca
3. Incorporation filing via corporateonline.gov.bc.ca
4. .ca domain + email + web presence provisioning (HestiaCP)
5. Canadian phone number provisioning
6. Corporate binder compilation (DOCX PDF)
7. Business banking link delivery
8. CRTC registration letter generation (Voice, Data & Wireless Reseller)
9. CCTS registration
All Playwright methods are structural stubs CSS selectors in config.py
must be populated after manual portal inspection before going live.
"""
from __future__ import annotations
import logging
import os
import re
import secrets
import asyncio
import imaplib
import email
from email.header import decode_header, make_header
from datetime import datetime
from pathlib import Path
from typing import Optional
from scripts.formation.base import (
FilingResult,
FilingStatus,
FormationOrder,
NameSearchResult,
StatePortal,
)
from .config import CONFIG
LOG = logging.getLogger("formation.bc")
# Steps with open selector verification gaps from live Corporate Online flow.
COLIN_UNVERIFIED_STEP_SELECTORS = {
6: ["inc_director_name", "inc_director_address"],
7: ["inc_share_structure"],
8: ["inc_articles"],
9: ["pay_card_number", "pay_card_exp", "pay_card_cvv", "pay_card_name", "pay_submit"],
12: ["inc_submit"],
}
# DOCX template for CRTC letter (in templates/ directory)
CRTC_TEMPLATE = os.getenv(
"CRTC_LETTER_TEMPLATE",
str(Path(__file__).resolve().parent.parent.parent.parent / "templates" / "crtc_notification_letter.docx"),
)
class BCPortal(StatePortal):
"""Adapter for BC Registry Services (Corporate Online) and Anytime Mailbox."""
STATE_CODE = "BC"
STATE_NAME = "British Columbia"
PORTAL_NAME = "Corporate Online"
PORTAL_URL = CONFIG["filing_portal"]["url"]
SUPPORTS_LLC = False # Canada has no LLC entity type
SUPPORTS_CORP = True
SUPPORTS_ONLINE_FILING = True
SUPPORTS_NAME_SEARCH = True
# No NW Registered Agent in Canada — we use Anytime Mailbox instead
NWRA_ADDRESS = ""
NWRA_CITY = ""
NWRA_STATE = ""
NWRA_ZIP = ""
CONFIG = CONFIG
def _missing_selectors(self, keys: list[str]) -> list[str]:
selectors = CONFIG["selectors"]
return [k for k in keys if not str(selectors.get(k, "")).strip()]
def _missing_colin_step_map(self) -> dict[int, list[str]]:
missing: dict[int, list[str]] = {}
for step, keys in COLIN_UNVERIFIED_STEP_SELECTORS.items():
gaps = self._missing_selectors(keys)
if gaps:
missing[step] = gaps
return missing
def _decode_header_value(self, raw: str) -> str:
if not raw:
return ""
try:
return str(make_header(decode_header(raw)))
except Exception:
return raw
def _extract_otp_candidate(self, text: str) -> str:
if not text:
return ""
match = re.search(r"(?:verification|security|one[-\s]?time|otp|code)[^\d]{0,30}(\d{6})", text, re.IGNORECASE)
if match:
return match.group(1)
fallback = re.search(r"\b(\d{6})\b", text)
return fallback.group(1) if fallback else ""
def _fetch_anytime_otp_sync(self, expected_recipient: str) -> str:
imap_host = os.getenv("ANYTIME_MAILBOX_IMAP_HOST", os.getenv("RELAY_IMAP_HOST", "mail.performancewest.net"))
imap_port = int(os.getenv("ANYTIME_MAILBOX_IMAP_PORT", os.getenv("RELAY_IMAP_PORT", "993")))
imap_ssl = os.getenv("ANYTIME_MAILBOX_IMAP_SSL", "true").lower() == "true"
imap_user = os.getenv("ANYTIME_MAILBOX_IMAP_USER", "").strip()
imap_pass = os.getenv("ANYTIME_MAILBOX_IMAP_PASS", "").strip()
imap_folder = os.getenv("ANYTIME_MAILBOX_IMAP_FOLDER", "INBOX")
sender_hint = os.getenv("ANYTIME_MAILBOX_OTP_SENDER_HINT", "anytimemailbox")
if not imap_user or not imap_pass:
self.log.warning("Anytime OTP auto-fetch disabled: IMAP credentials missing")
return ""
client: imaplib.IMAP4 | imaplib.IMAP4_SSL
client = imaplib.IMAP4_SSL(imap_host, imap_port) if imap_ssl else imaplib.IMAP4(imap_host, imap_port)
try:
client.login(imap_user, imap_pass)
client.select(imap_folder)
status, data = client.search(None, "ALL")
if status != "OK" or not data or not data[0]:
return ""
msg_ids = data[0].split()[-40:]
for msg_id in reversed(msg_ids):
fetch_status, parts = client.fetch(msg_id, "(RFC822)")
if fetch_status != "OK" or not parts:
continue
raw = parts[0][1] if isinstance(parts[0], tuple) and len(parts[0]) > 1 else b""
if not raw:
continue
msg = email.message_from_bytes(raw)
subj = self._decode_header_value(msg.get("Subject", ""))
from_addr = self._decode_header_value(msg.get("From", ""))
to_addr = self._decode_header_value(msg.get("To", ""))
envelope = f"{subj}\n{from_addr}\n{to_addr}"
if sender_hint.lower() not in envelope.lower() and "verification" not in envelope.lower():
continue
if expected_recipient and expected_recipient.lower() not in to_addr.lower() and expected_recipient.lower() not in envelope.lower():
continue
body_text = ""
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
if ctype in ("text/plain", "text/html"):
payload = part.get_payload(decode=True) or b""
try:
body_text += payload.decode(part.get_content_charset() or "utf-8", errors="ignore") + "\n"
except Exception:
continue
else:
payload = msg.get_payload(decode=True) or b""
body_text = payload.decode(msg.get_content_charset() or "utf-8", errors="ignore")
otp = self._extract_otp_candidate(f"{envelope}\n{body_text}")
if otp:
return otp
return ""
finally:
try:
client.logout()
except Exception:
pass
async def _wait_for_anytime_otp(self, expected_recipient: str) -> str:
timeout_s = int(os.getenv("ANYTIME_MAILBOX_OTP_TIMEOUT_SECONDS", "180"))
poll_s = int(os.getenv("ANYTIME_MAILBOX_OTP_POLL_SECONDS", "6"))
elapsed = 0
while elapsed <= timeout_s:
otp = await asyncio.to_thread(self._fetch_anytime_otp_sync, expected_recipient)
if otp:
return otp
await asyncio.sleep(poll_s)
elapsed += poll_s
return ""
async def _click_first(self, candidates: list[str], timeout: int = 8000) -> bool:
if not self.page:
return False
for candidate in candidates:
if not candidate:
continue
locator = self.page.locator(candidate).first
try:
if await locator.count() > 0:
await locator.wait_for(state="visible", timeout=timeout)
await locator.click()
await self.human_delay(0.3, 0.8)
return True
except Exception:
continue
return False
async def _fill_first(self, candidates: list[str], value: str, timeout: int = 8000) -> bool:
if not self.page:
return False
for candidate in candidates:
if not candidate:
continue
locator = self.page.locator(candidate).first
try:
if await locator.count() > 0:
await locator.wait_for(state="visible", timeout=timeout)
await locator.fill(value)
await self.human_delay(0.2, 0.5)
return True
except Exception:
continue
return False
# ------------------------------------------------------------------ #
# Name Search & Reservation
# ------------------------------------------------------------------ #
async def search_name(self, name: str) -> NameSearchResult:
"""Search BC Registry for name availability.
Uses bcregistrynames.gov.bc.ca Name Request portal.
Stub selectors need portal inspection.
"""
self.log.info("Searching BC Registry for name: %s", name)
selectors = CONFIG["selectors"]
try:
page = await self.start_browser()
await page.goto(CONFIG["name_request_portal"]["url"])
await self.human_delay(2.0, 4.0)
await self.screenshot("name_search_start")
# --- STUB: fill in once selectors are captured ---
# await self.type_slowly(selectors["name_search_input"], name)
# await self.safe_click(selectors["name_search_submit"])
# await page.wait_for_load_state("networkidle", timeout=15000)
# await self.human_delay(1.5, 3.0)
# await self.screenshot("name_search_result")
#
# available_el = await page.query_selector(selectors["name_result_available"])
# unavailable_el = await page.query_selector(selectors["name_result_unavailable"])
#
# available = available_el is not None and unavailable_el is None
self.log.warning("BC name search selectors not configured — returning stub result")
return NameSearchResult(
available=False,
exact_match=False,
similar_names=[],
state_code="BC",
searched_name=name,
raw_response="STUB: selectors not yet configured",
)
except Exception as exc:
self.log.error("BC name search failed: %s", exc)
await self.screenshot("name_search_error")
return NameSearchResult(
available=False,
state_code="BC",
searched_name=name,
raw_response=str(exc),
)
finally:
await self.close_browser()
async def reserve_name(self, name: str) -> dict:
"""Submit a Name Request on bcregistrynames.gov.bc.ca.
Name reservations in BC are valid for 56 days and cost C$30.
Numbered companies skip this step entirely.
Returns:
dict with keys: success, nr_number (Name Request number), message
"""
self.log.info("Reserving name in BC: %s", name)
selectors = CONFIG["selectors"]
try:
page = await self.start_browser()
await page.goto(CONFIG["name_request_portal"]["url"])
await self.human_delay(2.0, 4.0)
# --- STUB: fill in once selectors are captured ---
# Step 1: Enter name
# await self.type_slowly(selectors["name_search_input"], name)
# await self.safe_click(selectors["name_search_submit"])
# await page.wait_for_load_state("networkidle", timeout=15000)
# await self.human_delay(1.5, 3.0)
#
# Step 2: Click reserve
# await self.safe_click(selectors["name_reserve_btn"])
# await self.human_delay(1.0, 2.0)
#
# Step 3: Pay C$30 via Relay card
# ... payment selectors ...
#
# Step 4: Capture NR number from confirmation page
self.log.warning("BC name reservation selectors not configured — returning stub")
await self.screenshot("name_reserve_stub")
return {
"success": False,
"nr_number": "",
"message": "STUB: selectors not yet configured",
}
except Exception as exc:
self.log.error("BC name reservation failed: %s", exc)
await self.screenshot("name_reserve_error")
return {
"success": False,
"nr_number": "",
"message": str(exc),
}
finally:
await self.close_browser()
# ------------------------------------------------------------------ #
# Incorporation Filing
# ------------------------------------------------------------------ #
async def file_incorporation(self, order: FormationOrder) -> FilingResult:
"""File BC incorporation via Corporate Online.
Full flow:
1. Login to Corporate Online
2. Start new incorporation
3. Enter company name (or use numbered company)
4. Enter registered office address (Anytime Mailbox)
5. Enter records office (same as registered)
6. Enter director(s)
7. Enter share structure
8. Upload/confirm articles
9. Pay C$350 via Relay virtual debit card
10. Capture BC incorporation number from confirmation
Stub selectors need portal inspection.
"""
self.log.info("Filing BC incorporation for: %s", order.entity_name)
selectors = CONFIG["selectors"]
missing_steps = self._missing_colin_step_map()
if missing_steps:
detail = "; ".join(
f"step {step}: {', '.join(keys)}" for step, keys in sorted(missing_steps.items())
)
self.log.warning("BC incorporation blocked — unverified COLIN selectors: %s", detail)
return FilingResult(
success=False,
status=FilingStatus.PENDING,
state_code="BC",
entity_name=order.entity_name,
filing_number="",
confirmation_number="",
error_message=f"COLIN selector verification required ({detail})",
)
try:
page = await self.start_browser()
# --- Step 1: Login ---
await page.goto(CONFIG["filing_portal"]["login_url"])
await self.human_delay(2.0, 4.0)
await self.screenshot("inc_login_page")
# await self.type_slowly(selectors["login_username"], os.getenv("BC_REGISTRY_USERNAME", ""))
# await self.type_slowly(selectors["login_password"], os.getenv("BC_REGISTRY_PASSWORD", ""))
# await self.safe_click(selectors["login_submit"])
# await page.wait_for_load_state("networkidle", timeout=15000)
# await self.human_delay(2.0, 4.0)
# await self.screenshot("inc_logged_in")
# --- Step 2: Start new incorporation ---
# Navigate to incorporation form
# await page.goto(CONFIG["filing_portal"]["url"] + "/incorporation/new")
# await self.human_delay(1.5, 3.0)
# --- Step 3: Company name ---
# await self.type_slowly(selectors["inc_company_name"], order.entity_name)
# await self.human_delay(0.5, 1.0)
# --- Step 4: Registered office (Anytime Mailbox) ---
office = CONFIG["registered_office"]
# await self.type_slowly(selectors["inc_registered_office_street"], office["street"])
# await self.type_slowly(selectors["inc_registered_office_city"], office["city"])
# await self.type_slowly(selectors["inc_registered_office_province"], office["province"])
# await self.type_slowly(selectors["inc_registered_office_postal"], office["postal_code"])
# --- Step 5: Records office = same as registered ---
# await self.safe_click(selectors["inc_records_office_same"])
# --- Step 6: Director(s) ---
# for member in order.members:
# await self.type_slowly(selectors["inc_director_name"], member.name)
# await self.type_slowly(selectors["inc_director_address"],
# f"{member.address}, {member.city}, {member.state} {member.zip_code}")
# await self.human_delay(0.5, 1.0)
# --- Step 7: Share structure ---
# await self.type_slowly(selectors["inc_share_structure"], str(order.shares_authorized))
# --- Step 8: Articles ---
# Default articles for BC are standard — just confirm
# await self.safe_click(selectors["inc_articles"])
# --- Step 9: Payment (C$350 via Relay card) ---
# payment_selectors = {
# "card_number_field": selectors["pay_card_number"],
# "card_exp_field": selectors["pay_card_exp"],
# "card_cvv_field": selectors["pay_card_cvv"],
# "card_name_field": selectors["pay_card_name"],
# "submit_payment_btn": selectors["pay_submit"],
# }
# await self.enter_payment(order, payment_selectors)
# --- Step 10: Capture confirmation ---
# await self.screenshot("inc_confirmation")
# bc_number = await page.text_content(".confirmation-number") # placeholder selector
self.log.warning("BC incorporation selectors not configured — returning stub")
await self.screenshot("inc_stub")
return FilingResult(
success=False,
status=FilingStatus.PENDING,
state_code="BC",
entity_name=order.entity_name,
filing_number="",
confirmation_number="",
error_message="STUB: selectors not yet configured",
)
except Exception as exc:
self.log.error("BC incorporation failed: %s", exc)
await self.screenshot("inc_error")
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code="BC",
entity_name=order.entity_name,
error_message=str(exc),
)
finally:
await self.close_browser()
# ------------------------------------------------------------------ #
# file_llc / file_corporation — required by StatePortal ABC
# ------------------------------------------------------------------ #
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""LLCs do not exist under Canadian law."""
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code="BC",
entity_name=order.entity_name,
error_message="LLCs are not available in Canada. Use file_incorporation() for a BC corporation.",
)
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File a BC corporation — delegates to file_incorporation()."""
return await self.file_incorporation(order)
# ------------------------------------------------------------------ #
# Anytime Mailbox Setup
# ------------------------------------------------------------------ #
async def scrape_available_units(self, location_url: str) -> list[str]:
"""Scrape available mailbox unit numbers from an Anytime Mailbox location page.
Navigates to the location, clicks through to the mailbox number selection step,
and extracts all available unit numbers from the dropdown.
Returns a list of available unit number strings (e.g. ["101", "205", "B438"]).
"""
self.log.info("Scraping available units from: %s", location_url)
units = []
try:
page = await self.start_browser()
await page.goto(location_url, wait_until="networkidle", timeout=30000)
await self.human_delay(2.0, 3.0)
# Click SELECT on the first/cheapest plan to enter signup flow
await self._click_first([
'button:has-text("Select")',
'a:has-text("Select")',
'button:has-text("SELECT")',
])
await self.human_delay(2.0, 3.0)
# Click yearly plan period if available
await self._click_first([
'button:has-text("Yearly")',
'label:has-text("Yearly")',
'button:has-text("Annual")',
])
await self.human_delay(1.0, 2.0)
# Click continue/select to get to mailbox number step
await self._click_first([
'button:has-text("Continue")',
'button:has-text("Select")',
'button:has-text("Next")',
])
await self.human_delay(2.0, 3.0)
# Extract unit numbers from dropdown/select element
# AMB uses a dropdown or list for available mailbox numbers
unit_options = await page.evaluate("""() => {
// Try select dropdowns
const selects = document.querySelectorAll('select');
for (const sel of selects) {
const opts = [...sel.options].filter(o => o.value && o.value !== '');
if (opts.length > 1) {
return opts.map(o => o.value || o.textContent.trim());
}
}
// Try radio buttons or clickable list items
const radios = document.querySelectorAll('input[type="radio"][name*="mailbox"], input[type="radio"][name*="unit"]');
if (radios.length > 0) {
return [...radios].map(r => r.value || r.parentElement?.textContent?.trim() || '');
}
// Try list items that look like unit numbers
const items = document.querySelectorAll('[class*="mailbox"], [class*="unit"], [data-unit]');
if (items.length > 0) {
return [...items].map(i => i.textContent?.trim() || i.getAttribute('data-unit') || '').filter(Boolean);
}
return [];
}""")
units = [str(u).strip() for u in (unit_options or []) if str(u).strip()]
self.log.info("Found %d available units at %s", len(units), location_url)
await self.screenshot("mailbox_units_available")
except Exception as e:
self.log.error("Failed to scrape units from %s: %s", location_url, e)
finally:
try:
await self.stop_browser()
except Exception:
pass
return units
async def signup_with_unit(self, order: FormationOrder, unit_number: str, location_url: str) -> dict:
"""Sign up for Anytime Mailbox with a specific pre-selected unit number.
Similar to setup_mailbox() but uses a specific location URL and unit number
that the client selected in the portal, instead of auto-picking.
Returns dict with success, unit_number, mailbox_id, account_email.
"""
self.log.info("Signing up at %s with unit %s for: %s", location_url, unit_number, order.entity_name)
try:
page = await self.start_browser()
await page.goto(location_url, wait_until="networkidle", timeout=30000)
await self.human_delay(2.0, 3.0)
# Click SELECT on the cheapest plan
await self._click_first([
'button:has-text("Select")',
'a:has-text("Select")',
])
await self.human_delay(2.0, 3.0)
# Select yearly
await self._click_first([
'button:has-text("Yearly")',
'label:has-text("Yearly")',
])
await self.human_delay(1.0, 2.0)
await self._click_first([
'button:has-text("Continue")',
'button:has-text("Select")',
])
await self.human_delay(2.0, 3.0)
# Select the specific unit number from dropdown
selects = await page.query_selector_all("select")
unit_selected = False
for sel in selects:
opts = await sel.evaluate("el => [...el.options].map(o => o.value)")
if unit_number in opts:
await sel.select_option(unit_number)
unit_selected = True
break
if not unit_selected:
# Try clicking the unit in a list/radio
await self._click_first([
f'input[value="{unit_number}"]',
f'label:has-text("{unit_number}")',
f'[data-unit="{unit_number}"]',
])
await self.human_delay(1.0, 2.0)
# Now proceed with the rest of the signup flow (same as setup_mailbox)
member_name = order.members[0].name if order.members else order.regulatory_contact_name or "Client Name"
name_parts = member_name.split(" ", 1)
first_name = name_parts[0]
last_name = name_parts[1] if len(name_parts) > 1 else "Client"
signup_email = (
os.getenv("ANYTIME_MAILBOX_SIGNUP_EMAIL", "").strip()
or f'mailbox+{order.order_id.lower()}@performancewest.net'
)
signup_phone = order.regulatory_contact_phone or os.getenv("ANYTIME_MAILBOX_SIGNUP_PHONE", "+16025550123")
signup_password = os.getenv("ANYTIME_MAILBOX_DEFAULT_PASSWORD", "").strip() or f"Pw!{secrets.token_hex(8)}"
await self._fill_first(['input[name*="first" i]'], first_name)
await self._fill_first(['input[name*="last" i]'], last_name)
await self._fill_first(['input[name*="business" i]'], order.entity_name)
await self._click_first(['button:has-text("Continue")', 'button:has-text("Next")'])
await self.human_delay(1.5, 2.5)
# Contact details
full_street = order.principal_address or order.mailing_address or "5307 Victoria Dr"
city = order.principal_city or order.mailing_city or "Vancouver"
province = order.principal_state or order.mailing_state or "BC"
postal = order.principal_zip or order.mailing_zip or "V5P 3V6"
await self._fill_first(['input[name*="address" i]'], full_street)
await self._fill_first(['input[name*="city" i]'], city)
await self._fill_first(['input[name*="state" i]', 'input[name*="province" i]'], province)
await self._fill_first(['input[name*="zip" i]', 'input[name*="postal" i]'], postal)
await self._fill_first(['input[type="email"]'], signup_email)
await self._fill_first(['input[type="tel"]'], signup_phone)
await self._fill_first(['input[type="password"]'], signup_password)
await self._click_first(['button:has-text("Continue")', 'button:has-text("Next")'])
await self.human_delay(1.5, 2.5)
# OTP verification
otp_code = os.getenv("ANYTIME_MAILBOX_OTP_CODE", "").strip()
if not otp_code:
otp_code = await self._wait_for_anytime_otp(signup_email)
if otp_code:
await self._fill_first([
'input[name*="verification" i]',
'input[name*="otp" i]',
'input[inputmode="numeric"]',
], otp_code)
await self._click_first(['button:has-text("Verify")', 'button:has-text("Continue")'])
else:
await self.screenshot("mailbox_signup_waiting_otp")
return {
"success": False,
"unit_number": unit_number,
"mailbox_id": "",
"message": "OTP required: set ANYTIME_MAILBOX_OTP_CODE and retry",
}
# Checkout
await self._click_first([
'button:has-text("Continue")',
'button:has-text("Checkout")',
'button:has-text("Submit")',
])
await page.wait_for_load_state("networkidle", timeout=30000)
await self.human_delay(1.5, 3.0)
await self.screenshot("mailbox_signup_after_checkout")
page_text = (await page.content()) or ""
id_match = re.search(r"(?:Mailbox\s*ID|ID)\s*[:#]?\s*([A-Za-z0-9\-]{4,})", page_text, re.IGNORECASE)
mailbox_id = id_match.group(1) if id_match else ""
return {
"success": True,
"unit_number": unit_number,
"mailbox_id": mailbox_id,
"message": "Mailbox signup completed",
"account_email": signup_email,
}
except Exception as e:
self.log.error("Mailbox signup failed: %s", e)
await self.screenshot("mailbox_signup_error")
return {
"success": False,
"unit_number": unit_number,
"mailbox_id": "",
"message": f"Signup failed: {e}",
}
async def setup_mailbox(self, order: FormationOrder) -> dict:
"""Register an Anytime Mailbox account at 329 Howe St, Vancouver.
Flow:
1. Navigate to anytimemailbox.com
2. Select the Vancouver - Howe St location
3. Choose the Silver plan (C$164.99/yr)
4. Complete checkout with client details
5. Capture mailbox unit number for the registered office address
Returns:
dict with keys: success, unit_number, mailbox_id, message
"""
self.log.info("Setting up Anytime Mailbox for: %s", order.entity_name)
selectors = CONFIG["selectors"]
office_id = CONFIG.get("registered_office_default", "victoria-dr")
office = CONFIG.get("registered_office_locations", {}).get(office_id, CONFIG["registered_office"])
try:
page = await self.start_browser()
provider_url = office.get("provider_url") or CONFIG["registered_office"]["provider_url"]
await page.goto(provider_url)
await self.human_delay(2.0, 4.0)
await self.screenshot("mailbox_start")
# Step 1: location lookup and select plan
await self._fill_first(
[selectors.get("amb_location_search", ""), 'input[placeholder*="city" i]', 'input[type="search"]'],
f'{office.get("city", "Vancouver")} {office.get("province", "BC")}',
)
await self._click_first(
[
selectors.get("amb_location_select", ""),
f'text={office.get("street", "")}',
'button:has-text("Select")',
]
)
# Step 2: pick plan (yearly preferred)
await self._click_first(
[
selectors.get("amb_plan_period_yearly", ""),
'button:has-text("Yearly")',
'label:has-text("Yearly")',
]
)
await self._click_first(
[
selectors.get("amb_plan_select", ""),
f'text={office.get("plan", "Basic")}',
'button:has-text("Select")',
'button:has-text("Continue")',
]
)
# Step 3: mailbox number + identity details
await self._click_first([
selectors.get("amb_mailbox_number_first", ""),
'button:has-text("Choose")',
'button:has-text("Select")',
])
member_name = order.members[0].name if order.members else order.regulatory_contact_name or "Client Name"
name_parts = member_name.split(" ", 1)
first_name = name_parts[0]
last_name = name_parts[1] if len(name_parts) > 1 else "Client"
signup_email = (
os.getenv("ANYTIME_MAILBOX_SIGNUP_EMAIL", "").strip()
or f'mailbox+{order.order_id.lower()}@performancewest.net'
)
signup_phone = order.regulatory_contact_phone or os.getenv("ANYTIME_MAILBOX_SIGNUP_PHONE", "+16025550123")
signup_password = os.getenv("ANYTIME_MAILBOX_DEFAULT_PASSWORD", "").strip() or f"Pw!{secrets.token_hex(8)}"
await self._fill_first([selectors.get("amb_first_name", ""), 'input[name*="first" i]'], first_name)
await self._fill_first([selectors.get("amb_last_name", ""), 'input[name*="last" i]'], last_name)
await self._fill_first([selectors.get("amb_business_name", ""), 'input[name*="business" i]'], order.entity_name)
await self._click_first([
selectors.get("amb_continue", ""),
'button:has-text("Continue")',
'button:has-text("Next")',
])
# Step 4: contact details + account credentials
full_street = order.principal_address or order.mailing_address or "5307 Victoria Dr"
city = order.principal_city or order.mailing_city or office.get("city", "Vancouver")
province = order.principal_state or order.mailing_state or office.get("province", "BC")
postal = order.principal_zip or order.mailing_zip or office.get("postal_code", "V5P 3V6")
await self._fill_first([selectors.get("amb_home_address", ""), 'input[name*="address" i]'], full_street)
await self._fill_first([selectors.get("amb_home_city", ""), 'input[name*="city" i]'], city)
await self._fill_first([selectors.get("amb_home_state", ""), 'input[name*="state" i]', 'input[name*="province" i]'], province)
await self._fill_first([selectors.get("amb_home_postal", ""), 'input[name*="zip" i]', 'input[name*="postal" i]'], postal)
await self._fill_first([selectors.get("amb_email", ""), 'input[type="email"]'], signup_email)
await self._fill_first([selectors.get("amb_phone", ""), 'input[type="tel"]'], signup_phone)
await self._fill_first([selectors.get("amb_password", ""), 'input[type="password"]'], signup_password)
await self._click_first([
selectors.get("amb_continue", ""),
'button:has-text("Continue")',
'button:has-text("Next")',
])
# Step 5: OTP verification (required).
otp_code = os.getenv("ANYTIME_MAILBOX_OTP_CODE", "").strip()
if not otp_code:
otp_code = await self._wait_for_anytime_otp(signup_email)
if otp_code:
await self._fill_first(
[
selectors.get("amb_otp", ""),
'input[name*="verification" i]',
'input[name*="otp" i]',
'input[inputmode="numeric"]',
],
otp_code,
)
await self._click_first([
selectors.get("amb_otp_submit", ""),
'button:has-text("Verify")',
'button:has-text("Continue")',
])
else:
await self.screenshot("mailbox_waiting_otp")
return {
"success": False,
"unit_number": "",
"mailbox_id": "",
"message": "OTP required: set ANYTIME_MAILBOX_OTP_CODE and retry",
}
# Step 6: review + checkout
await self._click_first([
selectors.get("amb_checkout_submit", ""),
'button:has-text("Continue")',
'button:has-text("Checkout")',
'button:has-text("Submit")',
])
await page.wait_for_load_state("networkidle", timeout=30000)
await self.human_delay(1.5, 3.0)
await self.screenshot("mailbox_after_checkout")
page_text = (await page.content()) or ""
unit_match = re.search(r"(?:Suite|Unit|Mailbox|#)\s*([A-Za-z0-9\-]+)", page_text, re.IGNORECASE)
id_match = re.search(r"(?:Mailbox\s*ID|ID)\s*[:#]?\s*([A-Za-z0-9\-]{4,})", page_text, re.IGNORECASE)
mailbox_unit = unit_match.group(1) if unit_match else ""
mailbox_id = id_match.group(1) if id_match else ""
return {
"success": True,
"unit_number": mailbox_unit,
"mailbox_id": mailbox_id,
"message": "Anytime Mailbox signup submitted",
"account_email": signup_email,
}
except Exception as exc:
self.log.error("Anytime Mailbox setup failed: %s", exc)
await self.screenshot("mailbox_error")
return {
"success": False,
"unit_number": "",
"mailbox_id": "",
"message": str(exc),
}
finally:
await self.close_browser()
# ------------------------------------------------------------------ #
# CRTC Notification Letter
# ------------------------------------------------------------------ #
async def generate_crtc_letter(self, order: FormationOrder) -> Optional[str]:
"""Generate a CRTC notification letter from the DOCX template.
Fills the template with:
- Corporation name, BC incorporation number, date
- Registered office address (Anytime Mailbox)
- Director name(s)
- CRTC Secretary General address
Returns:
Path to the generated PDF, or None on failure.
"""
self.log.info("Generating CRTC letter for: %s", order.entity_name)
try:
from docx import Document
import subprocess
import tempfile
template_path = Path(CRTC_TEMPLATE)
if not template_path.exists():
self.log.error("CRTC letter template not found: %s", template_path)
return None
doc = Document(str(template_path))
office = CONFIG["registered_office"]
crtc = CONFIG["crtc"]
# Build director list
directors = ", ".join(m.name for m in order.members) if order.members else "N/A"
# Variable replacements
variables = {
"{{date}}": datetime.utcnow().strftime("%B %d, %Y"),
"{{entity_name}}": order.entity_name,
"{{bc_number}}": order.state_filing_number or "PENDING",
"{{incorporation_date}}": order.filed_at or datetime.utcnow().strftime("%B %d, %Y"),
"{{registered_office}}": (
f"{office['street']}, {office['city']}, "
f"{office['province']} {office['postal_code']}"
),
"{{directors}}": directors,
"{{crtc_address}}": (
f"{crtc['secretary_general']}\n"
f"{crtc['address']}\n"
f"{crtc['city']}, {crtc['province']} {crtc['postal_code']}"
),
}
# Replace placeholders in paragraphs
for paragraph in doc.paragraphs:
for key, value in variables.items():
if key in paragraph.text:
for run in paragraph.runs:
if key in run.text:
run.text = run.text.replace(key, value)
# Replace placeholders in tables
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
for key, value in variables.items():
if key in paragraph.text:
for run in paragraph.runs:
if key in run.text:
run.text = run.text.replace(key, value)
# Save DOCX
work_dir = tempfile.mkdtemp(prefix="pw_crtc_")
docx_path = os.path.join(work_dir, f"crtc_letter_{order.order_id}.docx")
doc.save(docx_path)
self.log.info("CRTC letter DOCX saved: %s", docx_path)
# Convert to PDF via LibreOffice
result = subprocess.run(
[
"libreoffice", "--headless",
"--convert-to", "pdf",
"--outdir", work_dir,
docx_path,
],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
self.log.error("LibreOffice conversion failed: %s", result.stderr)
return None
pdf_path = os.path.join(work_dir, f"crtc_letter_{order.order_id}.pdf")
if not Path(pdf_path).exists():
self.log.error("CRTC letter PDF not generated at: %s", pdf_path)
return None
self.log.info("CRTC letter PDF generated: %s", pdf_path)
return pdf_path
except Exception as exc:
self.log.error("CRTC letter generation failed: %s", exc)
return None
# Module-level convenience instance
adapter = BCPortal()

View file

@ -0,0 +1,615 @@
"""
Configuration for British Columbia, Canada BC Business Corporations Act.
BC uses a provincial incorporation system (not federal), governed by the
BC Business Corporations Act (SBC 2002, c. 57). Entities formed here are
BC corporations LLCs do not exist under Canadian law.
Portal stack:
- Corporate Online (corporateonline.gov.bc.ca) filing & annual reports
NOTE: No login required for new incorporations the wizard is anonymous
and payment is taken by credit card at the end. Do NOT attempt username/
password auth the login page is IDIR-only (government employees).
- BC Registry Name Request (bcregistrynames.gov.bc.ca) name reservation
- Anytime Mailbox (anytimemailbox.com) virtual mailbox for registered office
- CRTC Canadian Radio-television and Telecommunications Commission
(requires notification letter for telecom carriers)
Currency: all fees in CAD (C$).
COLIN wizard steps (in order):
1. Initial Information company name / effective date
2. Incorporator Info incorporator name + address
3. Completing Party person completing the filing
4. Translated Name (skip not applicable)
5. Director Info director name(s) + address(es)
6. Office Addresses registered office + records office
7. Share Structure share classes (Common shares, no par value)
8. Notification email for receipt
9. Company Information confirm name + type
10. Confirm Company Info review everything
11. Ready to Pay credit card entry
12. Your Receipt BC incorporation number
"""
CONFIG = {
"jurisdiction": "British Columbia",
"country": "Canada",
"abbreviation": "BC",
"entity_types": ["corporation"], # No LLCs in Canada
# ------------------------------------------------------------------ #
# Portal schedule — BC Corporate Online hours + BC holidays
# MonSat 6 AM 10 PM, Sun 1 PM 10 PM Pacific
# ------------------------------------------------------------------ #
"portal_schedule": {
"timezone": "America/Vancouver",
"jurisdiction": "BC",
"closed_holidays": True,
"hours": {
"mon": [6, 22],
"tue": [6, 22],
"wed": [6, 22],
"thu": [6, 22],
"fri": [6, 22],
"sat": [6, 22],
"sun": [13, 22],
},
},
# ------------------------------------------------------------------ #
# BC Registry — Corporate Online
# No authentication required — anonymous public filing portal.
# ------------------------------------------------------------------ #
"agency": "BC Registry Services",
"agency_url": "https://www.bcregistry.gov.bc.ca",
"filing_portal": {
"name": "Corporate Online",
"url": "https://www.corporateonline.gov.bc.ca",
# Direct URL to start a new Incorporation Application (anonymous)
"icorp_start_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/accesstransaction/menu.do?action=startFiling&filingTypeCode=ICORP&from=main",
"icorp_overview_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/accesstransaction/menu.do?action=overview&filingTypeCode=ICORP&from=main",
# Annual report
"annual_report_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/accesstransaction/menu.do?action=startFiling&filingTypeCode=ANNBC&from=main",
# Legacy — kept for compat; IDIR-only now (not used for automation)
"login_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/login/login.do",
},
"name_request_portal": {
"name": "BC Registry Name Request",
"url": "https://www.bcregistrynames.gov.bc.ca",
"search_url": "https://www.bcregistrynames.gov.bc.ca/nrSearch/name-search",
},
# ------------------------------------------------------------------ #
# Registered & Records Office — Anytime Mailbox (BC locations)
# ------------------------------------------------------------------ #
"registered_office_default": "victoria-dr",
"registered_office_locations": {
"victoria-dr": {
"id": "victoria-dr",
"label": "Vancouver - Victoria Dr (Best Value)",
"street": "5307 Victoria Dr",
"suite_prefix": "Suite",
"city": "Vancouver",
"province": "BC",
"postal_code": "V5P 3V6",
"country": "Canada",
"plan": "Basic",
"plan_cost_cad": 99.00,
"plan_period": "yearly",
"default": True,
},
"howe-st": {
"id": "howe-st",
"label": "Vancouver - Howe St (Downtown)",
"street": "329 Howe St",
"suite_prefix": "Unit",
"city": "Vancouver",
"province": "BC",
"postal_code": "V6C 3N2",
"country": "Canada",
"plan": "Silver",
"plan_cost_cad": 164.99,
"plan_period": "yearly",
"default": False,
},
"broadway": {
"id": "broadway",
"label": "Vancouver - Broadway",
"street": "1275 W Broadway",
"suite_prefix": "Suite",
"city": "Vancouver",
"province": "BC",
"postal_code": "V6H 1G2",
"country": "Canada",
"plan": "Silver",
"plan_cost_cad": 149.99,
"plan_period": "yearly",
"default": False,
},
},
# Legacy field — kept for backward compatibility
"registered_office": {
"provider": "Anytime Mailbox",
"provider_url": "https://www.anytimemailbox.com",
"location": "Vancouver - Victoria Dr",
"street": "5307 Victoria Dr",
"city": "Vancouver",
"province": "BC",
"postal_code": "V5P 3V6",
"country": "Canada",
"plan": "Basic",
"plan_cost_cad": 99.00,
"plan_period": "yearly",
},
# ------------------------------------------------------------------ #
# CRTC
# ------------------------------------------------------------------ #
"crtc": {
"name": "Canadian Radio-television and Telecommunications Commission",
"short_name": "CRTC",
"secretary_general": "Secretary General, CRTC",
"address": "1 Promenade du Portage",
"city": "Gatineau",
"province": "QC",
"postal_code": "J8X 4B1",
"country": "Canada",
"website": "https://crtc.gc.ca",
"notification_required": True,
},
# ------------------------------------------------------------------ #
# BITS (Basic International Telecommunications Services)
# ------------------------------------------------------------------ #
"bits": {
"name": "BITS Registration",
"description": (
"All Canadian telecom carriers must register with the CRTC "
"under the Basic International Telecommunications Services (BITS) regime. "
"Registration is filed via letter to the CRTC Secretary General."
),
"filing_method": "letter", # submitted with the CRTC notification letter
"annual_fee_cad": 0.00, # no fee for initial BITS notification
"renewal_required": False, # initial registration is a one-time notification
},
# ------------------------------------------------------------------ #
# CCTS (Commission for Complaints for Telecom-television Services)
# ------------------------------------------------------------------ #
"ccts": {
"name": "Commission for Complaints for Telecom-television Services",
"short_name": "CCTS",
"website": "https://www.ccts-cprst.ca",
"membership_url": "https://www.ccts-cprst.ca/for-service-providers/become-a-member/",
"description": (
"All Canadian telecom service providers must participate in the CCTS, "
"the national and independent organization dedicated to resolving "
"customer complaints about telecom and TV services. "
"Membership application is submitted online."
),
"filing_method": "online_form",
"annual_fee_cad": 0.00, # no fee for small carriers in first year
"renewal_required": True,
"renewal_period": "Yearly",
},
# ------------------------------------------------------------------ #
# GCKey — Government of Canada authentication credential
# Used to access My CRTC Account for electronic filings.
# Each carrier gets its own GCKey. Signup is a 5-step Spring Web Flow
# wizard with hCaptcha invisible on the username step.
# ------------------------------------------------------------------ #
"gckey": {
"name": "GCKey",
"description": "Government of Canada authentication credential for online services",
"homepage": "https://www.gckey.gc.ca",
"auth_domain": "clegc-gckey.gc.ca",
# SAML entry: go through CRTC SmartForms → GACS → GCKey
"saml_entry_url": "https://services.crtc.gc.ca/Pro/SmartForms/?_gc_lang=eng",
"signup_path": "/j/eng/rg", # append ?ReqID=... from SAML flow
# Signup wizard — 5 steps (Spring Web Flow)
"signup_steps": {
# Step 1: Terms and Conditions
"terms": {
"execution": "e1s1",
"accept_btn": "input[name=_eventId_accept]",
"decline_btn": "input[name=_eventId_cancel]",
},
# Step 2: Create Username
"username": {
"execution": "e1s2",
"field": "input[name=uid][id=userID]",
"submit_btn": "input[name=_eventId_submit][id=button]",
"hcaptcha_sitekey": "99871bd1-7b22-417a-b6cc-7ef645e5147a",
},
# Step 3: Create Password (selectors to be verified on first live run)
"password": {
"execution": "e1s3", # inferred — may be e1s3 or later
"field": "input[type=password][name=pwd]", # inferred
"confirm_field": "input[type=password][name=confirmPwd]", # inferred
"submit_btn": "input[name=_eventId_submit]",
},
# Step 4: Recovery Questions
"security_questions": {
"execution": "e1s4", # inferred
"question_selects": "select", # multiple <select> elements
"answer_inputs": "input[type=text]", # answer fields
"submit_btn": "input[name=_eventId_submit]",
},
# Step 5: Recovery Email (to be verified)
"email": {
"execution": "e1s5", # inferred
"field": "input[type=email], input[name=email]", # inferred
"submit_btn": "input[name=_eventId_submit]",
},
},
# Login page — used after account creation to verify the credentials work
"login_selectors": {
"username": "input[name=token1][id=token1]",
"password": "input[name=token2][id=token2]",
"signin_btn": "button[id=button]",
"csrf": "input[name=_csrf]",
"hcaptcha_sitekey": "c745648c-d973-4223-99af-8d178dc17a6c",
},
# Username format: pw-{bc_number} — deterministic per carrier
"username_prefix": "pw-",
# Password rules (from GCKey help page — to be verified)
"password_rules": {
"min_length": 8,
"max_length": 16,
"require_upper": True,
"require_lower": True,
"require_digit": True,
"require_special": True,
},
},
# ------------------------------------------------------------------ #
# ATS — CRTC Annual Telecommunications Survey
# All registered carriers must file annually via My CRTC Account (GCKey).
# ------------------------------------------------------------------ #
"ats": {
"name": "CRTC Annual Telecommunications Survey",
"portal_url": "https://services.crtc.gc.ca/Pro/SmartForms/?_gc_lang=eng",
"gckey_url": "https://www.gckey.gc.ca",
"my_crtc_url": "http://crtc.gc.ca/eng/forms/form_index.htm",
# Activation code: required for first electronic submission.
# Obtained by calling CRTC at 1-877-249-2782 or included in
# registration confirmation letter (30-60 days after filing).
"activation_code_phone": "1-877-249-2782",
"forms": {
"rep_t1": {
"name": "REP-T/T1 — Annual Telecommunications Survey",
"description": "Core annual survey for all telecom service providers",
"deadline_month": 3,
"deadline_day": 1,
"threshold": "All registered carriers must file — no revenue threshold",
"required_for_new_carriers": True,
},
"rep_u": {
"name": "REP-U — Universal Broadband Fund Survey",
"description": "Survey for carriers participating in broadband fund",
"deadline_month": 3,
"deadline_day": 31,
"threshold": "Carriers with >$10M CAD annual Canadian telecom revenue",
"required_for_new_carriers": False,
},
"form_802a": {
"name": "Form 802a — Contribution Survey",
"description": "Annual contribution obligation calculation",
"deadline_month": 3,
"deadline_day": 31,
"threshold": "Carriers with >$10M CAD annual Canadian telecom revenue",
"required_for_new_carriers": False,
},
"form_802j": {
"name": "Form 802j — Contribution Eligibility Survey",
"description": "For carriers seeking subsidy eligibility under the national contribution fund",
"deadline_month": 3,
"deadline_day": 31,
"threshold": "Only carriers seeking contribution fund subsidy eligibility",
"required_for_new_carriers": False,
},
},
"related_surveys": {
"facilities": {
"name": "Annual Facilities Survey",
"description": "Network infrastructure details for carriers owning/operating telecom facilities",
"deadline_month": 3,
"deadline_day": 31,
"threshold": "Carriers owning or operating telecom network facilities",
"required_for_new_carriers": False,
},
"pricing": {
"name": "Annual Communications Pricing Survey",
"description": "Pricing data for telecom services offered",
"deadline_month": 3,
"deadline_day": 31,
"threshold": "Carriers with >$10M CAD annual Canadian telecom revenue",
"required_for_new_carriers": False,
},
},
},
# ------------------------------------------------------------------ #
# BC Corporate Tax & Filing Obligations
# Assumes calendar fiscal year-end (Dec 31). If the client chooses a
# non-standard fiscal year, these dates need adjustment.
# ------------------------------------------------------------------ #
"corporate_obligations": {
"t2_return": {
"name": "Federal T2 Corporate Income Tax Return",
"description": (
"All Canadian corporations must file a T2 return with the CRA, "
"even if there is no tax owing or no business activity. "
"Filed electronically via CRA My Business Account or certified tax software."
),
"deadline_description": "6 months after fiscal year-end",
"deadline_month": 6, # June 30 for Dec 31 year-end
"deadline_day": 30,
"required": True,
"penalty": "5% of unpaid tax + 1%/month for up to 12 months",
"cra_url": "https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/corporations/corporation-income-tax-return.html",
},
"t2_tax_payment": {
"name": "Federal/Provincial Corporate Tax Payment",
"description": (
"Corporate income tax balance owing is due earlier than the T2 return. "
"For Canadian-Controlled Private Corporations (CCPCs) with taxable income "
"under $500K, payment is due 3 months after year-end. "
"Interest accrues on late payments."
),
"deadline_description": "3 months after fiscal year-end (CCPCs under $500K)",
"deadline_month": 3, # March 31 for Dec 31 year-end
"deadline_day": 31,
"required": True,
},
"gst_hst_return": {
"name": "GST/HST Return",
"description": (
"If registered for GST/HST (required if revenue > $30K/yr, "
"recommended to register voluntarily for input tax credits). "
"Annual filers: due 3 months after fiscal year-end. "
"Telecom services are generally GST/HST taxable."
),
"deadline_description": "3 months after fiscal year-end (annual filers)",
"deadline_month": 3, # March 31 for Dec 31 year-end
"deadline_day": 31,
"threshold": "Mandatory if >$30K revenue; voluntary registration recommended",
"required_for_new_carriers": True, # should register voluntarily
},
"t4_t4a_slips": {
"name": "T4/T4A Information Slips",
"description": (
"If the corporation has employees or contractors paid >$500/yr, "
"T4 (employment) and/or T4A (contractor) slips must be filed. "
"Not applicable for most new shell telecom corporations."
),
"deadline_description": "Last day of February following the calendar year",
"deadline_month": 2,
"deadline_day": 28,
"threshold": "Only if corporation has employees or pays contractors >$500/yr",
"required_for_new_carriers": False,
},
"bc_pst": {
"name": "BC Provincial Sales Tax (PST) Return",
"description": (
"Most telecom services in BC are subject to 7% PST. "
"If registered as a PST collector, returns are due monthly, "
"quarterly, or annually depending on volume. "
"New carriers should consult with an accountant about PST obligations."
),
"threshold": "If collecting PST on taxable telecom services",
"required_for_new_carriers": False, # depends on service type
},
"worksafebc": {
"name": "WorkSafeBC Annual Return",
"description": (
"Required if the corporation has employees in BC. "
"Annual return reports payroll for workers' compensation premium calculation. "
"Not applicable for corporations with no employees."
),
"deadline_description": "March 1 following the calendar year",
"deadline_month": 3,
"deadline_day": 1,
"threshold": "Only if corporation has BC employees",
"required_for_new_carriers": False,
},
"crtc_registration_update": {
"name": "CRTC Annual Registration Update",
"description": (
"The CRTC contacts registered carriers annually to verify and update "
"registration information. Must respond to maintain active registration status. "
"The CRTC initiates this — you just need to respond."
),
"deadline_description": "Respond within 30 days of CRTC contact (typically Q1)",
"required": True,
},
},
# ------------------------------------------------------------------ #
# Fees (CAD)
# ------------------------------------------------------------------ #
"fees": {
"name_reservation": 30.00,
"incorporation": 350.00,
"annual_report": 42.00,
"mailbox_yearly": 164.99,
"currency": "CAD",
},
# ------------------------------------------------------------------ #
# Playwright selectors — BC Corporate Online (Struts / classic HTML)
#
# IMPORTANT NOTES ON THE PORTAL:
# - No login required for Incorporation Application (anonymous filing)
# - All form fields follow Struts DTO naming: fedDto.*
# - Wizard is a multi-page POST flow; Playwright must follow the
# "Next" / "Continue" buttons between pages
# - Payment is by credit card — use Relay virtual debit card
# - After payment, the BC incorporation number appears on the receipt
#
# Selector status:
# ✓ CONFIRMED from live portal HTML (fetched 2026-04-04)
# ~ INFERRED from Struts DTO naming convention + wizard step structure
# ✗ UNVERIFIED — needs manual inspection in a live session
# ------------------------------------------------------------------ #
"selectors": {
# ── Step 0: No login required ────────────────────────────────────
# Navigate directly to icorp_start_url (anonymous)
"login_username": "", # Not used — portal is anonymous
"login_password": "", # Not used
"login_submit": "", # Not used
# ── Step 1: Initial Information ──────────────────────────────────
# URL: .../menu.do?action=startFiling&filingTypeCode=ICORP&from=main
# Confirmed from live HTML fetch 2026-04-04:
# - Radio button for numbered company: value="NMBRD"
# - Name reservation number input field is present
# - Effective date selects: fedDto.effectiveDateTime.*
"inc_numbered_company_radio": "input[type='radio'][value='NMBRD']", # ✓ confirmed
"inc_nr_number": "input[name='fedDto.nameReservationNumber']", # ~ inferred
"inc_effective_immediately": "input[type='radio'][value='immediate']", # ~ inferred
"inc_next_btn": "input[type='submit'][value='Next >']", # ~ inferred
# ── Step 2: Incorporator Info ────────────────────────────────────
# Who is incorporating the company. We list Performance West Inc.
# as the incorporator (agent on behalf of client).
"inc_incorporator_first": "input[name='fedDto.incorporatorDto.firstName']", # ~ inferred
"inc_incorporator_last": "input[name='fedDto.incorporatorDto.lastName']", # ~ inferred
"inc_incorporator_org": "input[name='fedDto.incorporatorDto.orgName']", # ~ inferred (org name)
"inc_incorporator_addr1": "input[name='fedDto.incorporatorDto.address.addr1']", # ~ inferred
"inc_incorporator_city": "input[name='fedDto.incorporatorDto.address.city']", # ~ inferred
"inc_incorporator_prov": "select[name='fedDto.incorporatorDto.address.province']", # ~ inferred
"inc_incorporator_postal":"input[name='fedDto.incorporatorDto.address.postalCd']", # ~ inferred
"inc_incorporator_country":"select[name='fedDto.incorporatorDto.address.country']", # ~ inferred
# ── Step 3: Completing Party ─────────────────────────────────────
# Person completing this filing (same as incorporator for us).
"inc_completing_same_chk": "input[type='checkbox'][name*='sameasincorporator' i]", # ~ inferred
"inc_completing_first": "input[name='fedDto.completingPartyDto.firstName']", # ~ inferred
"inc_completing_last": "input[name='fedDto.completingPartyDto.lastName']", # ~ inferred
"inc_completing_phone": "input[name='fedDto.completingPartyDto.phoneNumber']", # ~ inferred
"inc_completing_email": "input[name='fedDto.completingPartyDto.email']", # ~ inferred
# ── Step 5: Director Info ────────────────────────────────────────
# Director 1 (the client is typically sole director)
# These are the UNVERIFIED selectors flagged in adapter.py.
"inc_director_name": "input[name='fedDto.directorDtos[0].fullName']", # ✗ unverified
"inc_director_first": "input[name='fedDto.directorDtos[0].firstName']", # ✗ unverified
"inc_director_last": "input[name='fedDto.directorDtos[0].lastName']", # ✗ unverified
"inc_director_addr1": "input[name='fedDto.directorDtos[0].address.addr1']", # ✗ unverified
"inc_director_city": "input[name='fedDto.directorDtos[0].address.city']", # ✗ unverified
"inc_director_prov": "select[name='fedDto.directorDtos[0].address.province']",# ✗ unverified
"inc_director_postal": "input[name='fedDto.directorDtos[0].address.postalCd']", # ✗ unverified
"inc_director_country": "select[name='fedDto.directorDtos[0].address.country']", # ✗ unverified
# Legacy combined field (some COLIN versions use a single fullName input)
"inc_director_address": "input[name='fedDto.directorDtos[0].address.addr1']", # ✗ unverified
# ── Step 6: Office Addresses ─────────────────────────────────────
# Registered office = Anytime Mailbox address + unit number
"inc_registered_office_street": "input[name='fedDto.regOfficeDto.deliveryAddress.addr1']", # ✗ unverified
"inc_registered_office_city": "input[name='fedDto.regOfficeDto.deliveryAddress.city']", # ✗ unverified
"inc_registered_office_province":"select[name='fedDto.regOfficeDto.deliveryAddress.province']", # ✗ unverified
"inc_registered_office_postal": "input[name='fedDto.regOfficeDto.deliveryAddress.postalCd']", # ✗ unverified
# Records office same as registered — typical checkbox
"inc_records_office_same": "input[type='checkbox'][name*='recordsSame' i]", # ✗ unverified
# company_name is shown as a label here; not a text input at this step
"inc_company_name": "input[name='fedDto.nameReservationNumber']", # only for NR# path
# ── Step 7: Share Structure ──────────────────────────────────────
# Standard structure: 1 class, "Common Shares", unlimited, no par value
# COLIN presents a pre-filled Table 1 Articles option (checkbox to adopt)
"inc_share_structure": "input[type='checkbox'][name*='adoptTable1' i]", # ✗ unverified
"inc_table1_adopt": "input[type='checkbox'][name*='adoptTable1' i]", # ✗ unverified
"inc_share_class_name": "input[name='fedDto.shareDtos[0].className']", # ✗ unverified
"inc_share_max": "input[name='fedDto.shareDtos[0].maxShares']", # ✗ unverified
# Articles file upload — only needed if NOT using Table 1
"inc_articles": "input[type='file'][name*='articles' i]", # ✗ unverified
# ── Step 8: Notification ─────────────────────────────────────────
"inc_notification_email": "input[name='fedDto.notificationEmail']", # ~ inferred
# ── Step 11: Ready to Pay (credit card) ──────────────────────────
# COLIN uses a standard card form at checkout
"pay_card_number": "input[name='cardNumber'], input[id*='cardNumber' i], input[autocomplete='cc-number']", # ✗ unverified
"pay_card_exp": "input[name='expiryDate'], input[id*='expiry' i], input[autocomplete='cc-exp']", # ✗ unverified
"pay_card_cvv": "input[name='cvv'], input[name='cvd'], input[id*='cvv' i], input[autocomplete='cc-csc']", # ✗ unverified
"pay_card_name": "input[name='cardholderName'], input[id*='cardHolder' i], input[autocomplete='cc-name']", # ✗ unverified
"pay_submit": "input[type='submit'][value*='Pay' i], button:has-text('Pay Now'), input[type='submit'][value*='Submit Payment' i]", # ✗ unverified
# ── Step 12: Submit / Confirmation ───────────────────────────────
"inc_submit": "input[type='submit'][value*='Submit' i], input[type='submit'][value*='Confirm' i]", # ✗ unverified
# Confirmation / receipt page — BC incorporation number
"inc_confirmation_number": ".confirmation-number, td:has-text('Incorporation Number') + td, td.dataValue", # ✗ unverified
# ── Name Request portal (bcregistrynames.gov.bc.ca) ──────────────
# Modern Vue.js SPA — selectors are data-test or aria attributes
"name_search_input": "input[id='business-name'], input[placeholder*='Enter name' i], input[data-test='business-name']", # ~ inferred
"name_search_submit": "button[data-test='search-btn'], button:has-text('Search')", # ~ inferred
"name_result_available": ".v-chip--label:has-text('Available'), .available-badge, [class*='available']", # ~ inferred
"name_result_unavailable":"[class*='not-available'], [class*='unavailable'], .v-chip:has-text('Not Available')", # ~ inferred
"name_reserve_btn": "button:has-text('Reserve'), button[data-test='reserve-btn']", # ~ inferred
# ── Anytime Mailbox ───────────────────────────────────────────────
"amb_location_search": "input[placeholder*='city' i], input[placeholder*='search' i]",
"amb_email": "input[type='email'], input[name*='email' i]",
"amb_password": "input[type='password'], input[name*='password' i]",
"amb_phone": "input[type='tel'], input[name*='phone' i]",
"amb_first_name": "input[name*='firstName' i], input[name*='first_name' i], input[placeholder*='First name' i]",
"amb_last_name": "input[name*='lastName' i], input[name*='last_name' i], input[placeholder*='Last name' i]",
"amb_business_name": "input[name*='business' i], input[placeholder*='Business name' i]",
"amb_home_address": "input[name*='address' i]:not([name*='email' i]), input[placeholder*='Street' i]",
"amb_home_city": "input[name*='city' i]",
"amb_home_state": "select[name*='state' i], select[name*='province' i]",
"amb_home_postal": "input[name*='postal' i], input[name*='zip' i]",
"amb_plan_select": "button:has-text('Select'), a:has-text('Select')",
"amb_plan_period_yearly": "input[type='radio'][value*='year' i], label:has-text('Yearly')",
"amb_location_select": "button:has-text('Choose'), button:has-text('Select this location')",
"amb_mailbox_number_first":"select option:first-child, .mailbox-select option:nth-child(2)",
"amb_continue": "button:has-text('Continue'), button:has-text('Next')",
"amb_otp": "input[name*='otp' i], input[name*='code' i], input[placeholder*='verification' i]",
"amb_otp_submit": "button:has-text('Verify'), button:has-text('Submit')",
"amb_checkout_submit": "button:has-text('Complete'), button:has-text('Subscribe'), button:has-text('Pay')",
# ── Annual Report ─────────────────────────────────────────────────
"ar_filing_year": "select[name*='year' i], input[name*='year' i]",
"ar_confirm_address": "input[type='checkbox'][name*='confirm' i]",
"ar_submit": "input[type='submit'], button[type='submit']",
},
# ------------------------------------------------------------------ #
# Selector verification status
# Tracks which step selectors have been confirmed against the live portal.
# The adapter checks this before running to prevent half-complete filings.
# ------------------------------------------------------------------ #
"COLIN_UNVERIFIED_STEP_SELECTORS": {
# Steps 5-12 need live session verification.
# Remove a step once confirmed to unblock that part of the pipeline.
5: ["inc_director_first", "inc_director_last", "inc_director_addr1"],
6: ["inc_registered_office_street", "inc_records_office_same"],
7: ["inc_share_structure", "inc_table1_adopt"],
9: ["pay_card_number", "pay_card_exp", "pay_card_cvv", "pay_submit"],
12: ["inc_submit", "inc_confirmation_number"],
},
# ------------------------------------------------------------------ #
# Notes
# ------------------------------------------------------------------ #
"notes": (
"BC Business Corporations Act (SBC 2002, c. 57) requirements:\n"
" - Must have at least one director (can be non-resident).\n"
" - Registered office AND records office must be in BC.\n"
" - We use Anytime Mailbox at client's chosen BC location as both.\n"
" - Name reservation is optional but recommended (valid 56 days).\n"
" - Numbered companies do not require a name reservation.\n"
" - Annual Report due within 2 months of anniversary date.\n"
" - CRTC notification required for telecom service providers.\n"
" - All fees in Canadian Dollars (CAD).\n"
" - Corporate Online filing portal is ANONYMOUS — no login required.\n"
" Payment by Visa/MC/Amex at the end of the wizard.\n"
" Use Relay virtual debit card (SID-0002) for filing payment."
),
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,119 @@
"""California — SOS portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class CAPortal(StatePortal):
STATE_CODE = "CA"
STATE_NAME = "California"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search California business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in California."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement California-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
# NOTE: California imposes an annual franchise tax of $800/yr.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for California",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in California."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for California",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> CAPortal:
return CAPortal()

View file

@ -0,0 +1,49 @@
"""California — Secretary of State portal configuration."""
CONFIG = {
"state_code": "CA",
"state_name": "California",
"sos_name": "California Secretary of State",
"portal_name": "California bizfile Online",
"portal_url": "https://sos.ca.gov",
"name_search_url": "https://businesssearch.sos.ca.gov",
"filing_url": "https://bizfileonline.sos.ca.gov",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "1800 S Brand Blvd Ste 201",
"nwra_city": "Glendale",
"nwra_state": "CA",
"nwra_zip": "91204",
# State fees (cents)
"llc_formation_fee": 7000,
"corp_formation_fee": 10000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "California imposes an annual franchise tax of $800/yr for LLCs and corporations.",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,177 @@
"""Colorado — Socrata API for name search, Playwright for filing.
Colorado publishes business entity data on data.colorado.gov via the
Socrata Open Data API (SODA). Dataset ID: 4ykn-tg5h.
This allows name availability searches WITHOUT a headless browser.
Filing still requires Playwright against the SOS web portal.
"""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
from scripts.formation.base import (
StatePortal, NameSearchResult, FormationOrder, FilingResult,
FilingStatus,
)
from .config import CONFIG
SOCRATA_BASE = "https://data.colorado.gov/resource/4ykn-tg5h.json"
class COPortal(StatePortal):
STATE_CODE = "CO"
STATE_NAME = "Colorado"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Colorado business name availability via Socrata SODA API.
Uses the free REST API at data.colorado.gov no browser, no login,
no rate-limit issues for moderate usage. Returns JSON directly.
"""
try:
# SoQL query: find entities whose name contains our search term
upper_name = name.upper().replace("'", "''")
query = f"$where=upper(entityname) like '%25{urllib.parse.quote(upper_name)}%25'"
query += "&$limit=20&$order=entityformdate DESC"
url = f"{SOCRATA_BASE}?{query}"
self.log.info("CO Socrata API query: %s", url)
req = urllib.request.Request(
url,
headers={"Accept": "application/json", "User-Agent": "PerformanceWest/1.0"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
# Check for exact match (case-insensitive)
exact_match = any(
r.get("entityname", "").upper().strip() == upper_name.strip()
for r in data
)
# Collect similar names
similar_names = [
r.get("entityname", "").strip()
for r in data[:10]
if r.get("entityname", "").strip()
]
available = not exact_match
self.log.info(
"CO name search: '%s'%s (exact_match=%s, similar=%d)",
name, "AVAILABLE" if available else "TAKEN", exact_match, len(similar_names),
)
return NameSearchResult(
available=available,
exact_match=exact_match,
similar_names=similar_names,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=json.dumps(data[:5]),
)
except urllib.error.URLError as e:
self.log.error("CO Socrata API request failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=f"Socrata API error: {e}",
)
except Exception as e:
self.log.error("CO name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=f"Error: {e}",
)
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC Articles of Organization in Colorado.
Colorado SOS portal: sos.state.co.us
Filing fee: $50
Online filing is immediate (no processing delay).
TODO: Implement Playwright filing flow.
"""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("co_llc_start")
# Verify name first via Socrata API (no browser needed)
name_result = await self.search_name(order.entity_name)
if not name_result.available:
return FilingResult(
success=False,
status=FilingStatus.NAME_UNAVAILABLE,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=f"Name '{order.entity_name}' is not available in Colorado. "
f"Similar: {', '.join(name_result.similar_names[:5])}",
)
# TODO: Complete filing flow once portal selectors are mapped
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="CO LLC filing: name search via API works, "
"filing form selectors pending portal walkthrough",
)
except Exception as e:
self.log.error("CO LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Articles of Incorporation in Colorado ($50)."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="CO Corp filing pending — LLC flow first",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> COPortal:
return COPortal()

View file

@ -0,0 +1,49 @@
"""Colorado — Secretary of State portal configuration."""
CONFIG = {
"state_code": "CO",
"state_name": "Colorado",
"sos_name": "Colorado Secretary of State",
"portal_name": "Colorado Business Database",
"portal_url": "https://sos.state.co.us",
"name_search_url": "https://sos.state.co.us",
"filing_url": "https://sos.state.co.us",
"search_method": "socrata_api",
# Socrata API
"socrata_domain": "data.colorado.gov",
"socrata_dataset_id": "4ykn-tg5h",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "7700 E Arapahoe Rd Ste 110",
"nwra_city": "Centennial",
"nwra_state": "CO",
"nwra_zip": "80112",
# State fees (cents)
"llc_formation_fee": 5000,
"corp_formation_fee": 5000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,118 @@
"""Connecticut — SOTS portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class CTPortal(StatePortal):
STATE_CODE = "CT"
STATE_NAME = "Connecticut"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Connecticut business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Connecticut."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Connecticut-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Connecticut",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Connecticut."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Connecticut",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> CTPortal:
return CTPortal()

View file

@ -0,0 +1,49 @@
"""Connecticut — Secretary of the State portal configuration."""
CONFIG = {
"state_code": "CT",
"state_name": "Connecticut",
"sos_name": "Connecticut Secretary of the State",
"portal_name": "Connecticut Online Business Search",
"portal_url": "https://portal.ct.gov/sots",
"name_search_url": "https://service.ct.gov/business/s/onlinebusinesssearch",
"filing_url": "https://service.ct.gov/business/s/onlinebusinesssearch",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "40 Old Ridgebury Rd Ste 205",
"nwra_city": "Danbury",
"nwra_state": "CT",
"nwra_zip": "06810",
# State fees (cents)
"llc_formation_fee": 12000,
"corp_formation_fee": 25000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,70 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class DCPortal(StatePortal):
"""District of Columbia DLCP portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the DC business name database.
Args:
page: Playwright page instance.
name: Business name to search for.
Returns:
dict with 'available' (bool) and 'results' (list).
"""
await page.goto(CONFIG["search_url"])
search_input = CONFIG["selectors"]["search_input"]
search_button = CONFIG["selectors"]["search_button"]
results_table = CONFIG["selectors"]["results_table"]
if search_input:
await page.fill(search_input, name)
if search_button:
await page.click(search_button)
if results_table:
await page.wait_for_selector(results_table)
return {"available": False, "results": [], "status": "not yet implemented"}
async def file_llc(self, page: Page, payload: dict) -> dict:
"""File an LLC formation with the DC DLCP ($99).
Args:
page: Playwright page instance.
payload: Formation data including name, agent, members.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
async def file_corporation(self, page: Page, payload: dict) -> dict:
"""File a Corporation formation with the DC DLCP ($99).
Args:
page: Playwright page instance.
payload: Formation data including name, agent, directors.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
adapter = DCPortal()

View file

@ -0,0 +1,29 @@
CONFIG = {
"state": "DC",
"state_name": "District of Columbia",
"agency": "DLCP",
"agency_name": "Department of Licensing and Consumer Protection",
"portal_url": "https://dcra.dc.gov",
"search_url": "https://corponline.dcra.dc.gov/BizEntity.aspx/Search",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "611 Pennsylvania Ave SE Ste 443",
"city": "Washington",
"state": "DC",
"zip": "20003",
},
"fees": {
"llc": 99,
"corporation": 99,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
"notes": "$300 biennial report.",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,119 @@
"""Delaware — Division of Corporations portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class DEPortal(StatePortal):
STATE_CODE = "DE"
STATE_NAME = "Delaware"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Delaware business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Delaware."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Delaware-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
# NOTE: Delaware imposes an annual franchise tax of $300/yr for LLCs.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Delaware",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Delaware."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Delaware",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> DEPortal:
return DEPortal()

View file

@ -0,0 +1,68 @@
"""Delaware — Division of Corporations portal configuration."""
CONFIG = {
"state_code": "DE",
"state_name": "Delaware",
"sos_name": "Delaware Division of Corporations",
"portal_name": "Delaware ICIS Entity Search",
"portal_url": "https://corp.delaware.gov",
"name_search_url": "https://icis.corp.delaware.gov/ecorp/entitysearch/namesearch.aspx",
"filing_url": "https://icis.corp.delaware.gov/ecorp/entitysearch",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "8 The Green Ste A",
"nwra_city": "Dover",
"nwra_state": "DE",
"nwra_zip": "19901",
# State fees (cents)
"llc_formation_fee": 11000,
"corp_formation_fee": 8900,
"expedited_fee": 50000,
"expedited_label": "24-hour",
# VERIFIED selectors from live portal HTML (2026-03-19)
"selectors": {
# Name search (namesearch.aspx) — ASP.NET WebForms with __VIEWSTATE
"name_search_input": "#ctl00_ContentPlaceHolder1_frmEntityName",
"file_number_input": "#ctl00_ContentPlaceHolder1_frmFileNumber",
"name_search_submit": "#ctl00_ContentPlaceHolder1_btnSubmit",
"error_label": "#ctl00_ContentPlaceHolder1_lblError",
"error_message": "#ctl00_ContentPlaceHolder1_lblErrorMessage",
"name_results_table": "#tblResults",
"name_available_indicator": "", # No results = name available
"name_unavailable_indicator": "", # Results present = name taken
# CAPTCHA
"captcha_panel": "#ctl00_ContentPlaceHolder1_pnlCaptcha",
"captcha_image_base": "/Ecorp/CaptchaHandler.ashx?type=image&key=",
# Honeypot field (hidden)
"honeypot_field": "input[name='email_confirm']",
# LLC filing form selectors — NOT YET VERIFIED (requires active filing session)
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": (
"Delaware imposes an annual franchise tax of $300/yr for LLCs. "
"CRITICAL: Name search has CAPTCHA on every request (image-based, in pnlCaptcha div). "
"Anti-scraping warning on portal: 'The Division of Corporations strictly prohibits mining data. "
"Use of automated tools in any form may result in the suspension of your access.' "
"Need 2captcha or anticaptcha integration for automated name search. "
"Portal uses ASP.NET WebForms with __VIEWSTATE — must maintain session cookies. "
"Hidden honeypot field 'email_confirm' must be left empty. "
"JavaScript cookie 'js_token' set via btoa(Date.now()) required. "
"$5,000 for 1-hour rush, $1,000 for same-day, $500 for 24-hour."
),
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,118 @@
"""Florida — Sunbiz portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class FLPortal(StatePortal):
STATE_CODE = "FL"
STATE_NAME = "Florida"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Florida business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Florida."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Florida-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Florida",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Florida."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Florida",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> FLPortal:
return FLPortal()

View file

@ -0,0 +1,49 @@
"""Florida — Division of Corporations (Sunbiz) portal configuration."""
CONFIG = {
"state_code": "FL",
"state_name": "Florida",
"sos_name": "Florida Division of Corporations",
"portal_name": "Sunbiz",
"portal_url": "https://sunbiz.org",
"name_search_url": "https://search.sunbiz.org/Inquiry/CorporationSearch/ByName",
"filing_url": "https://sunbiz.org",
"search_method": "sftp_bulk",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "8 S. Tennessee Ave Ste 104",
"nwra_city": "Lakeland",
"nwra_state": "FL",
"nwra_zip": "33801",
# State fees (cents)
"llc_formation_fee": 12500,
"corp_formation_fee": 7000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,118 @@
"""Georgia — SOS portal automation."""
from __future__ import annotations
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
from .config import CONFIG
class GAPortal(StatePortal):
STATE_CODE = "GA"
STATE_NAME = "Georgia"
PORTAL_NAME = CONFIG["portal_name"]
PORTAL_URL = CONFIG["portal_url"]
NWRA_ADDRESS = CONFIG["nwra_address"]
NWRA_CITY = CONFIG["nwra_city"]
NWRA_STATE = CONFIG["nwra_state"]
NWRA_ZIP = CONFIG["nwra_zip"]
async def search_name(self, name: str) -> NameSearchResult:
"""Search Georgia business name availability."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"])
await self.human_delay()
# Type name into search field
sel = CONFIG["selectors"]
if sel["name_search_input"]:
await self.type_slowly(sel["name_search_input"], name)
await self.safe_click(sel["name_search_submit"])
await page.wait_for_load_state("networkidle")
content = await page.content()
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
return NameSearchResult(
available=available,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=content[:2000],
)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response="Selectors not yet configured for this state",
)
except Exception as e:
self.log.error("Name search failed: %s", e)
return NameSearchResult(
available=False,
state_code=self.STATE_CODE,
searched_name=name,
raw_response=str(e),
)
finally:
await self.close_browser()
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File LLC in Georgia."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
await self.screenshot("llc_start")
# TODO: Implement Georgia-specific LLC filing flow
# Each state's portal has different form fields, steps, and workflows.
# The selectors in config.py need to be populated by inspecting the portal.
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="LLC filing automation not yet implemented for Georgia",
screenshot_path=await self.screenshot("llc_not_implemented"),
)
except Exception as e:
self.log.error("LLC filing failed: %s", e)
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File Corporation in Georgia."""
try:
page = await self.start_browser()
await page.goto(CONFIG["filing_url"])
await self.human_delay()
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message="Corporation filing automation not yet implemented for Georgia",
)
except Exception as e:
return FilingResult(
success=False,
status=FilingStatus.ERROR,
state_code=self.STATE_CODE,
entity_name=order.entity_name,
error_message=str(e),
)
finally:
await self.close_browser()
def adapter() -> GAPortal:
return GAPortal()

View file

@ -0,0 +1,49 @@
"""Georgia — Secretary of State portal configuration."""
CONFIG = {
"state_code": "GA",
"state_name": "Georgia",
"sos_name": "Georgia Secretary of State",
"portal_name": "Georgia eCorp Business Search",
"portal_url": "https://sos.ga.gov",
"name_search_url": "https://ecorp.sos.ga.gov/BusinessSearch",
"filing_url": "https://ecorp.sos.ga.gov/BusinessSearch",
"search_method": "playwright",
# Socrata API (not applicable)
"socrata_domain": "",
"socrata_dataset_id": "",
# NW Registered Agent address in this state
"nwra_name": "Northwest Registered Agent LLC",
"nwra_address": "2985 Gordy Pkwy Ste 100",
"nwra_city": "Marietta",
"nwra_state": "GA",
"nwra_zip": "30066",
# State fees (cents)
"llc_formation_fee": 11000,
"corp_formation_fee": 11000,
"expedited_fee": None,
"expedited_label": "",
# Selectors (Playwright CSS selectors for portal automation)
"selectors": {
"name_search_input": "",
"name_search_submit": "",
"name_results_table": "",
"name_available_indicator": "",
"name_unavailable_indicator": "",
# LLC filing form selectors
"llc_name_field": "",
"llc_agent_name_field": "",
"llc_agent_address_field": "",
"llc_principal_address_field": "",
"llc_organizer_name_field": "",
"llc_management_type_select": "",
"llc_purpose_field": "",
"llc_submit_button": "",
# Corp filing form selectors
"corp_name_field": "",
"corp_agent_name_field": "",
"corp_shares_field": "",
"corp_submit_button": "",
},
"notes": "",
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class HIPortal(StatePortal):
"""Hawaii Business Registration Division portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Hawaii business name database.
Args:
page: Playwright page instance.
name: Business name to search for.
Returns:
dict with 'available' (bool) and 'results' (list).
"""
await page.goto(CONFIG["search_url"])
# TODO: populate selectors during portal inspection
search_input = CONFIG["selectors"]["search_input"]
search_button = CONFIG["selectors"]["search_button"]
results_table = CONFIG["selectors"]["results_table"]
if search_input:
await page.fill(search_input, name)
if search_button:
await page.click(search_button)
if results_table:
await page.wait_for_selector(results_table)
return {"available": False, "results": [], "status": "not yet implemented"}
async def file_llc(self, page: Page, payload: dict) -> dict:
"""File an LLC formation with the Hawaii BREG.
Args:
page: Playwright page instance.
payload: Formation data including name, agent, members.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
async def file_corporation(self, page: Page, payload: dict) -> dict:
"""File a Corporation formation with the Hawaii BREG.
Args:
page: Playwright page instance.
payload: Formation data including name, agent, directors.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
adapter = HIPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "HI",
"state_name": "Hawaii",
"agency": "BREG",
"agency_name": "Business Registration Division",
"portal_url": "https://cca.hawaii.gov/breg",
"search_url": "https://hbe.ehawaii.gov/documents/search.html",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "1003 Bishop St Ste 1400",
"city": "Honolulu",
"state": "HI",
"zip": "96813",
},
"fees": {
"llc": 50,
"corporation": 50,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class IAPortal(StatePortal):
"""Iowa Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Iowa business name database.
Uses Socrata open data API (data.iowa.gov) when available,
falls back to the SOS web portal.
Args:
page: Playwright page instance.
name: Business name to search for.
Returns:
dict with 'available' (bool) and 'results' (list).
"""
search_method = CONFIG.get("search_method", "web")
if search_method == "socrata":
# TODO: implement Socrata API search against data.iowa.gov
return {"available": False, "results": [], "status": "not yet implemented"}
await page.goto(CONFIG["search_url"])
# TODO: populate selectors during portal inspection
search_input = CONFIG["selectors"]["search_input"]
search_button = CONFIG["selectors"]["search_button"]
results_table = CONFIG["selectors"]["results_table"]
if search_input:
await page.fill(search_input, name)
if search_button:
await page.click(search_button)
if results_table:
await page.wait_for_selector(results_table)
return {"available": False, "results": [], "status": "not yet implemented"}
async def file_llc(self, page: Page, payload: dict) -> dict:
"""File an LLC formation with the Iowa SOS.
Args:
page: Playwright page instance.
payload: Formation data including name, agent, members.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
async def file_corporation(self, page: Page, payload: dict) -> dict:
"""File a Corporation formation with the Iowa SOS.
Args:
page: Playwright page instance.
payload: Formation data including name, agent, directors.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
adapter = IAPortal()

View file

@ -0,0 +1,30 @@
CONFIG = {
"state": "IA",
"state_name": "Iowa",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.iowa.gov",
"search_url": "https://sos.iowa.gov/search/business/search.aspx",
"search_method": "socrata",
"socrata_domain": "data.iowa.gov",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "1550 2nd Ave SE Ste 200",
"city": "Cedar Rapids",
"state": "IA",
"zip": "52401",
},
"fees": {
"llc": 50,
"corporation": 50,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

@ -0,0 +1,2 @@
from .adapter import adapter
from .config import CONFIG

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class IDPortal(StatePortal):
"""Idaho Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Idaho business name database.
Args:
page: Playwright page instance.
name: Business name to search for.
Returns:
dict with 'available' (bool) and 'results' (list).
"""
await page.goto(CONFIG["search_url"])
# TODO: populate selectors during portal inspection
search_input = CONFIG["selectors"]["search_input"]
search_button = CONFIG["selectors"]["search_button"]
results_table = CONFIG["selectors"]["results_table"]
if search_input:
await page.fill(search_input, name)
if search_button:
await page.click(search_button)
if results_table:
await page.wait_for_selector(results_table)
return {"available": False, "results": [], "status": "not yet implemented"}
async def file_llc(self, page: Page, payload: dict) -> dict:
"""File an LLC formation with the Idaho SOS.
Args:
page: Playwright page instance.
payload: Formation data including name, agent, members.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
async def file_corporation(self, page: Page, payload: dict) -> dict:
"""File a Corporation formation with the Idaho SOS.
Args:
page: Playwright page instance.
payload: Formation data including name, agent, directors.
Returns:
dict with filing confirmation or error details.
"""
await page.goto(CONFIG["portal_url"])
# TODO: implement actual filing flow during portal inspection
return {"filed": False, "status": "not yet implemented"}
adapter = IDPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "ID",
"state_name": "Idaho",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.idaho.gov",
"search_url": "https://sosbiz.idaho.gov/search/business",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "5680 E Franklin Rd Ste 250",
"city": "Nampa",
"state": "ID",
"zip": "83687",
},
"fees": {
"llc": 100,
"corporation": 100,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

Some files were not shown because too many files have changed in this diff Show more