Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
31
scripts/Dockerfile
Normal file
31
scripts/Dockerfile
Normal 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
140
scripts/alert.py
Normal 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
54
scripts/backup-db.sh
Executable 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
56
scripts/deploy-dev.sh
Executable 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
233
scripts/deploy-go-live.sh
Executable 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
97
scripts/deploy.sh
Executable 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"
|
||||
5
scripts/document_gen/__init__.py
Normal file
5
scripts/document_gen/__init__.py
Normal 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
|
||||
222
scripts/document_gen/docx_builder.py
Normal file
222
scripts/document_gen/docx_builder.py
Normal 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
|
||||
137
scripts/document_gen/llm_writer.py
Normal file
137
scripts/document_gen/llm_writer.py
Normal 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
|
||||
128
scripts/document_gen/minio_client.py
Normal file
128
scripts/document_gen/minio_client.py
Normal 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)
|
||||
285
scripts/document_gen/pdf_converter.py
Normal file
285
scripts/document_gen/pdf_converter.py
Normal 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
|
||||
0
scripts/document_gen/templates/__init__.py
Normal file
0
scripts/document_gen/templates/__init__.py
Normal file
213
scripts/document_gen/templates/calea_audio_bridge_generator.py
Normal file
213
scripts/document_gen/templates/calea_audio_bridge_generator.py
Normal 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)
|
||||
250
scripts/document_gen/templates/calea_clec_ss7_generator.py
Normal file
250
scripts/document_gen/templates/calea_clec_ss7_generator.py
Normal 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)
|
||||
219
scripts/document_gen/templates/calea_ixc_ss7_generator.py
Normal file
219
scripts/document_gen/templates/calea_ixc_ss7_generator.py
Normal 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)
|
||||
220
scripts/document_gen/templates/calea_satellite_generator.py
Normal file
220
scripts/document_gen/templates/calea_satellite_generator.py
Normal 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)
|
||||
308
scripts/document_gen/templates/calea_ssi_generator.py
Normal file
308
scripts/document_gen/templates/calea_ssi_generator.py
Normal 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)
|
||||
231
scripts/document_gen/templates/calea_wireless_generator.py
Normal file
231
scripts/document_gen/templates/calea_wireless_generator.py
Normal 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)
|
||||
229
scripts/document_gen/templates/calea_wireless_mvno_generator.py
Normal file
229
scripts/document_gen/templates/calea_wireless_mvno_generator.py
Normal 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)
|
||||
270
scripts/document_gen/templates/cdr_traffic_study_generator.py
Normal file
270
scripts/document_gen/templates/cdr_traffic_study_generator.py
Normal 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)
|
||||
263
scripts/document_gen/templates/cpni_audio_bridge_generator.py
Normal file
263
scripts/document_gen/templates/cpni_audio_bridge_generator.py
Normal 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)
|
||||
588
scripts/document_gen/templates/cpni_cert_letter_generator.py
Normal file
588
scripts/document_gen/templates/cpni_cert_letter_generator.py
Normal 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)
|
||||
366
scripts/document_gen/templates/cpni_clec_generator.py
Normal file
366
scripts/document_gen/templates/cpni_clec_generator.py
Normal 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)
|
||||
307
scripts/document_gen/templates/cpni_clec_reseller_generator.py
Normal file
307
scripts/document_gen/templates/cpni_clec_reseller_generator.py
Normal 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)
|
||||
289
scripts/document_gen/templates/cpni_ixc_generator.py
Normal file
289
scripts/document_gen/templates/cpni_ixc_generator.py
Normal 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)
|
||||
270
scripts/document_gen/templates/cpni_ixc_reseller_generator.py
Normal file
270
scripts/document_gen/templates/cpni_ixc_reseller_generator.py
Normal 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)
|
||||
225
scripts/document_gen/templates/cpni_private_line_generator.py
Normal file
225
scripts/document_gen/templates/cpni_private_line_generator.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
272
scripts/document_gen/templates/cpni_satellite_generator.py
Normal file
272
scripts/document_gen/templates/cpni_satellite_generator.py
Normal 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)
|
||||
282
scripts/document_gen/templates/cpni_wireless_generator.py
Normal file
282
scripts/document_gen/templates/cpni_wireless_generator.py
Normal 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)
|
||||
273
scripts/document_gen/templates/cpni_wireless_mvno_generator.py
Normal file
273
scripts/document_gen/templates/cpni_wireless_mvno_generator.py
Normal 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)
|
||||
252
scripts/document_gen/templates/crtc_letter_generator.py
Normal file
252
scripts/document_gen/templates/crtc_letter_generator.py
Normal 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)
|
||||
1326
scripts/document_gen/templates/fcc_499a_checklist_generator.py
Normal file
1326
scripts/document_gen/templates/fcc_499a_checklist_generator.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 303–314)", 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)
|
||||
146
scripts/document_gen/templates/guides/dno_list_enforcement.md
Normal file
146
scripts/document_gen/templates/guides/dno_list_enforcement.md
Normal 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*
|
||||
184
scripts/document_gen/templates/guides/kyc_procedures.md
Normal file
184
scripts/document_gen/templates/guides/kyc_procedures.md
Normal 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*
|
||||
|
|
@ -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*
|
||||
137
scripts/document_gen/templates/guides/traceback_response.md
Normal file
137
scripts/document_gen/templates/guides/traceback_response.md
Normal 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 |
|
||||
|---|---|
|
||||
| **0–1 hour** | Acknowledge receipt of traceback request |
|
||||
| **1–4 hours** | Search CDRs, identify the source |
|
||||
| **4–12 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*
|
||||
324
scripts/document_gen/templates/ocn_request_form_generator.py
Normal file
324
scripts/document_gen/templates/ocn_request_form_generator.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
602
scripts/document_gen/templates/rmd_exhibit_a_generator.py
Normal file
602
scripts/document_gen/templates/rmd_exhibit_a_generator.py
Normal 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()
|
||||
1110
scripts/document_gen/templates/rmd_letter_generator.py
Normal file
1110
scripts/document_gen/templates/rmd_letter_generator.py
Normal file
File diff suppressed because it is too large
Load diff
303
scripts/document_gen/traffic_study_stamper.py
Normal file
303
scripts/document_gen/traffic_study_stamper.py
Normal 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)
|
||||
1
scripts/formation/__init__.py
Normal file
1
scripts/formation/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Performance West — 50-State Business Formation Automation
|
||||
388
scripts/formation/base.py
Normal file
388
scripts/formation/base.py
Normal 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 []
|
||||
338
scripts/formation/bulk_download.py
Normal file
338
scripts/formation/bulk_download.py
Normal 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()
|
||||
444
scripts/formation/document_delivery.py
Normal file
444
scripts/formation/document_delivery.py
Normal 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 & 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 · Business Formation & Compliance Services
|
||||
</p>
|
||||
<p style="font-size:13px; color:#999; margin:4px 0 0;">
|
||||
Email: formations@performancewest.net · 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()
|
||||
666
scripts/formation/ein_worker.py
Normal file
666
scripts/formation/ein_worker.py
Normal 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 Mon–Fri, 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 Mon–Fri, 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: Mon–Fri 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: Mon–Fri, 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 Mon–Fri, 7 AM – 10 PM ET.")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(_main_standalone(sys.argv[1]))
|
||||
563
scripts/formation/formation_worker.py
Normal file
563
scripts/formation/formation_worker.py
Normal 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()
|
||||
304
scripts/formation/holidays.py
Normal file
304
scripts/formation/holidays.py
Normal 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],
|
||||
)
|
||||
313
scripts/formation/jurisdictions/__init__.py
Normal file
313
scripts/formation/jurisdictions/__init__.py
Normal 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",
|
||||
]
|
||||
178
scripts/formation/name_search.py
Normal file
178
scripts/formation/name_search.py
Normal 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)
|
||||
640
scripts/formation/operating_agreement.py
Normal file
640
scripts/formation/operating_agreement.py
Normal 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()
|
||||
232
scripts/formation/portal_schedule.py
Normal file
232
scripts/formation/portal_schedule.py
Normal 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))
|
||||
96
scripts/formation/states/__init__.py
Normal file
96
scripts/formation/states/__init__.py
Normal 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
|
||||
2
scripts/formation/states/ak/__init__.py
Normal file
2
scripts/formation/states/ak/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ak/adapter.py
Normal file
118
scripts/formation/states/ak/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/ak/config.py
Normal file
49
scripts/formation/states/ak/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/al/__init__.py
Normal file
2
scripts/formation/states/al/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/al/adapter.py
Normal file
118
scripts/formation/states/al/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/al/config.py
Normal file
49
scripts/formation/states/al/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/ar/__init__.py
Normal file
2
scripts/formation/states/ar/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ar/adapter.py
Normal file
118
scripts/formation/states/ar/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/ar/config.py
Normal file
49
scripts/formation/states/ar/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/az/__init__.py
Normal file
2
scripts/formation/states/az/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
119
scripts/formation/states/az/adapter.py
Normal file
119
scripts/formation/states/az/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/az/config.py
Normal file
49
scripts/formation/states/az/config.py
Normal 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.",
|
||||
}
|
||||
4
scripts/formation/states/bc/__init__.py
Normal file
4
scripts/formation/states/bc/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .config import CONFIG
|
||||
from .adapter import BCPortal
|
||||
|
||||
__all__ = ["CONFIG", "BCPortal"]
|
||||
977
scripts/formation/states/bc/adapter.py
Normal file
977
scripts/formation/states/bc/adapter.py
Normal 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()
|
||||
615
scripts/formation/states/bc/config.py
Normal file
615
scripts/formation/states/bc/config.py
Normal 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
|
||||
# Mon–Sat 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."
|
||||
),
|
||||
}
|
||||
2
scripts/formation/states/ca/__init__.py
Normal file
2
scripts/formation/states/ca/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
119
scripts/formation/states/ca/adapter.py
Normal file
119
scripts/formation/states/ca/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/ca/config.py
Normal file
49
scripts/formation/states/ca/config.py
Normal 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.",
|
||||
}
|
||||
2
scripts/formation/states/co/__init__.py
Normal file
2
scripts/formation/states/co/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
177
scripts/formation/states/co/adapter.py
Normal file
177
scripts/formation/states/co/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/co/config.py
Normal file
49
scripts/formation/states/co/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/ct/__init__.py
Normal file
2
scripts/formation/states/ct/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ct/adapter.py
Normal file
118
scripts/formation/states/ct/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/ct/config.py
Normal file
49
scripts/formation/states/ct/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/dc/__init__.py
Normal file
2
scripts/formation/states/dc/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
70
scripts/formation/states/dc/adapter.py
Normal file
70
scripts/formation/states/dc/adapter.py
Normal 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()
|
||||
29
scripts/formation/states/dc/config.py
Normal file
29
scripts/formation/states/dc/config.py
Normal 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.",
|
||||
}
|
||||
2
scripts/formation/states/de/__init__.py
Normal file
2
scripts/formation/states/de/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
119
scripts/formation/states/de/adapter.py
Normal file
119
scripts/formation/states/de/adapter.py
Normal 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()
|
||||
68
scripts/formation/states/de/config.py
Normal file
68
scripts/formation/states/de/config.py
Normal 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."
|
||||
),
|
||||
}
|
||||
2
scripts/formation/states/fl/__init__.py
Normal file
2
scripts/formation/states/fl/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/fl/adapter.py
Normal file
118
scripts/formation/states/fl/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/fl/config.py
Normal file
49
scripts/formation/states/fl/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/ga/__init__.py
Normal file
2
scripts/formation/states/ga/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ga/adapter.py
Normal file
118
scripts/formation/states/ga/adapter.py
Normal 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()
|
||||
49
scripts/formation/states/ga/config.py
Normal file
49
scripts/formation/states/ga/config.py
Normal 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": "",
|
||||
}
|
||||
2
scripts/formation/states/hi/__init__.py
Normal file
2
scripts/formation/states/hi/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/hi/adapter.py
Normal file
71
scripts/formation/states/hi/adapter.py
Normal 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()
|
||||
28
scripts/formation/states/hi/config.py
Normal file
28
scripts/formation/states/hi/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/ia/__init__.py
Normal file
2
scripts/formation/states/ia/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
80
scripts/formation/states/ia/adapter.py
Normal file
80
scripts/formation/states/ia/adapter.py
Normal 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()
|
||||
30
scripts/formation/states/ia/config.py
Normal file
30
scripts/formation/states/ia/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/id/__init__.py
Normal file
2
scripts/formation/states/id/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/id/adapter.py
Normal file
71
scripts/formation/states/id/adapter.py
Normal 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()
|
||||
28
scripts/formation/states/id/config.py
Normal file
28
scripts/formation/states/id/config.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue