Initial commit — Performance West telecom compliance platform

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class ILPortal(StatePortal):
"""Illinois Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Illinois business name database.
Uses Socrata open data API (data.illinois.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.illinois.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 Illinois 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 Illinois 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 = ILPortal()

View file

@ -0,0 +1,30 @@
CONFIG = {
"state": "IL",
"state_name": "Illinois",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://ilsos.gov",
"search_url": "https://apps.ilsos.gov/corporatellc/CorporateLlcController",
"search_method": "socrata",
"socrata_domain": "data.illinois.gov",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "33 N Dearborn St Ste 1210",
"city": "Chicago",
"state": "IL",
"zip": "60602",
},
"fees": {
"llc": 150,
"corporation": 150,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class INPortal(StatePortal):
"""Indiana Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Indiana 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 Indiana 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 Indiana 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 = INPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "IN",
"state_name": "Indiana",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://in.gov/sos",
"search_url": "https://bsd.sos.in.gov/publicbusinesssearch",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "8595 E Washington St Ste 200",
"city": "Indianapolis",
"state": "IN",
"zip": "46219",
},
"fees": {
"llc": 95,
"corporation": 95,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class KSPortal(StatePortal):
"""Kansas Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Kansas 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 Kansas 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 Kansas 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 = KSPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "KS",
"state_name": "Kansas",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.ks.gov",
"search_url": "https://www.kansas.gov/bess/flow/main",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "7021 W 79th St",
"city": "Overland Park",
"state": "KS",
"zip": "66204",
},
"fees": {
"llc": 160,
"corporation": 90,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class KYPortal(StatePortal):
"""Kentucky Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Kentucky 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 Kentucky 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 Kentucky 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 = KYPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "KY",
"state_name": "Kentucky",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.ky.gov",
"search_url": "https://app.sos.ky.gov/ftsearch",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "4965 US Hwy 42 Ste 1000",
"city": "Louisville",
"state": "KY",
"zip": "40222",
},
"fees": {
"llc": 40,
"corporation": 40,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class LAPortal(StatePortal):
"""Louisiana Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Louisiana 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 Louisiana 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 Louisiana 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 = LAPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "LA",
"state_name": "Louisiana",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.la.gov",
"search_url": "https://coraweb.sos.la.gov/commercialsearch/CommercialSearchAnon.aspx",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "1340 Poydras St Ste 1770",
"city": "New Orleans",
"state": "LA",
"zip": "70112",
},
"fees": {
"llc": 100,
"corporation": 75,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,73 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MAPortal(StatePortal):
"""Massachusetts Secretary of the Commonwealth portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Massachusetts 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 Massachusetts SOC.
Note: Massachusetts has the highest LLC filing fee at $500.
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 Massachusetts SOC.
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 = MAPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "MA",
"state_name": "Massachusetts",
"agency": "SOC",
"agency_name": "Secretary of the Commonwealth",
"portal_url": "https://sec.state.ma.us",
"search_url": "https://corp.sec.state.ma.us/corpweb/CorpSearch/CorpSearch.aspx",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "11 Beacon St Ste 1400",
"city": "Boston",
"state": "MA",
"zip": "02108",
},
"fees": {
"llc": 500,
"corporation": 275,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MDPortal(StatePortal):
"""Maryland SDAT portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Maryland 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 Maryland SDAT.
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 Maryland SDAT.
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 = MDPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "MD",
"state_name": "Maryland",
"agency": "SDAT",
"agency_name": "State Department of Assessments and Taxation",
"portal_url": "https://dat.maryland.gov",
"search_url": "https://egov.maryland.gov/BusinessExpress/EntitySearch",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "7 Saint Paul St Ste 820",
"city": "Baltimore",
"state": "MD",
"zip": "21202",
},
"fees": {
"llc": 100,
"corporation": 170,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MEPortal(StatePortal):
"""Maine Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Maine 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 Maine 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 Maine 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 = MEPortal()

View file

@ -0,0 +1,28 @@
CONFIG = {
"state": "ME",
"state_name": "Maine",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://maine.gov/sos",
"search_url": "https://icrs.informe.org/nei-sos-icrs/ICRS",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "20 Danforth St Ste 203",
"city": "Portland",
"state": "ME",
"zip": "04101",
},
"fees": {
"llc": 175,
"corporation": 145,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MIPortal(StatePortal):
"""Michigan LARA portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Michigan business name database.
Uses Socrata open data API (data.michigan.gov) when available,
falls back to the LARA 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.michigan.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 Michigan LARA.
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 Michigan LARA.
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 = MIPortal()

View file

@ -0,0 +1,30 @@
CONFIG = {
"state": "MI",
"state_name": "Michigan",
"agency": "LARA",
"agency_name": "Licensing and Regulatory Affairs",
"portal_url": "https://michigan.gov/lara",
"search_url": "https://cofs.lara.state.mi.us/SearchApi/Search/Search",
"search_method": "socrata",
"socrata_domain": "data.michigan.gov",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "2710 Woodward Ave Ste 200",
"city": "Detroit",
"state": "MI",
"zip": "48201",
},
"fees": {
"llc": 50,
"corporation": 60,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MNPortal(StatePortal):
"""Minnesota Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Minnesota 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 Minnesota 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 Minnesota 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 = MNPortal()

View file

@ -0,0 +1,29 @@
CONFIG = {
"state": "MN",
"state_name": "Minnesota",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.state.mn.us",
"search_url": "https://mblsportal.sos.state.mn.us/Business/Search",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "1000 Washington Ave S Ste 200",
"city": "Minneapolis",
"state": "MN",
"zip": "55415",
},
"fees": {
"llc": 155,
"corporation": 155,
},
"notes": "No RA required. No annual fee.",
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MOPortal(StatePortal):
"""Missouri Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Missouri 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 Missouri 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 Missouri 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 = MOPortal()

View file

@ -0,0 +1,29 @@
CONFIG = {
"state": "MO",
"state_name": "Missouri",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.mo.gov",
"search_url": "https://bsd.sos.mo.gov/BusinessEntity/BESearch.aspx",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "600 Washington Ave Ste 200",
"city": "St. Louis",
"state": "MO",
"zip": "63101",
},
"fees": {
"llc": 50,
"corporation": 58,
},
"notes": "No annual fees.",
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from playwright.async_api import Page
from scripts.formation.base import StatePortal
from .config import CONFIG
class MSPortal(StatePortal):
"""Mississippi Secretary of State portal adapter."""
config = CONFIG
async def search_name(self, page: Page, name: str) -> dict:
"""Search the Mississippi 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 Mississippi 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 Mississippi 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 = MSPortal()

View file

@ -0,0 +1,29 @@
CONFIG = {
"state": "MS",
"state_name": "Mississippi",
"agency": "SOS",
"agency_name": "Secretary of State",
"portal_url": "https://sos.ms.gov",
"search_url": "https://corp.sos.ms.gov/corp/portal/c/page/corpBusinessIdSearch/portal.aspx",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "5760 I-55 N Ste 200",
"city": "Jackson",
"state": "MS",
"zip": "39211",
},
"fees": {
"llc": 50,
"corporation": 50,
},
"notes": "No annual report.",
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"name_field": "",
"agent_name_field": "",
"agent_address_field": "",
"submit_button": "",
},
}

View file

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

View file

@ -0,0 +1,116 @@
"""Montana — SOS SOS portal automation.
Name search implemented via the public business entity search.
LLC/Corp filing selectors pending live portal verification.
"""
from __future__ import annotations
import re
from scripts.formation.base import (
StatePortal,
NameSearchResult,
FormationOrder,
FilingResult,
FilingStatus,
)
from .config import CONFIG
class MTPortal(StatePortal):
STATE_CODE = "MT"
STATE_NAME = "Montana"
PORTAL_NAME = "SOS"
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 Montana business name availability via the public portal."""
try:
page = await self.start_browser()
await page.goto(CONFIG["name_search_url"], wait_until="networkidle")
await self.human_delay(1.0, 2.5)
search_sel = (
CONFIG["selectors"].get("search_input")
or 'input[type="text"], input[name*="earch"], input[name*="ame"]'
)
await page.fill(search_sel, "")
await self.type_slowly(page, search_sel, name)
await self.human_delay(0.5, 1.0)
btn_sel = (
CONFIG["selectors"].get("search_button")
or 'button[type="submit"], input[type="submit"]'
)
await page.click(btn_sel)
await page.wait_for_load_state("networkidle")
await self.human_delay(1.0, 2.0)
content = await page.content()
await self.screenshot(page, f"${CODE}_name_search_{name}")
no_results = any(
phrase in content.lower()
for phrase in ["no match", "no results", "no records", "no entities", "0 results"]
)
if no_results:
return NameSearchResult(
available=True, exact_match=False, similar_names=[],
state_code="MT", searched_name=name,
raw_response=content[:2000],
)
similar: list[str] = []
pattern = re.compile(r'<td[^>]*>([^<]*?' + re.escape(name[:8]) + r'[^<]*?)</td>', re.IGNORECASE)
for m in pattern.finditer(content):
found = m.group(1).strip()
if found and 3 < len(found) < 200:
similar.append(found)
exact = any(
s.upper().replace(",", "").strip() == name.upper().replace(",", "").strip()
for s in similar
)
return NameSearchResult(
available=not exact, exact_match=exact,
similar_names=similar[:10], state_code="MT",
searched_name=name, raw_response=content[:2000],
)
except Exception as exc:
return NameSearchResult(
available=False, state_code="MT", searched_name=name,
raw_response=f"Error: {exc}",
)
async def file_llc(self, order: FormationOrder) -> FilingResult:
"""File an LLC in Montana. Selectors pending live portal verification."""
return FilingResult(
success=False, status=FilingStatus.PENDING,
state_code="MT", entity_name=order.entity_name,
error_message=(
"MT filing adapter selectors pending verification. "
f"Admin: file manually at {CONFIG['portal_url']} — LLC formation."
),
)
async def file_corporation(self, order: FormationOrder) -> FilingResult:
"""File a corporation in Montana. Selectors pending live portal verification."""
return FilingResult(
success=False, status=FilingStatus.PENDING,
state_code="MT", entity_name=order.entity_name,
error_message=(
"MT filing adapter selectors pending verification. "
f"Admin: file manually at {CONFIG['portal_url']} — Corp formation."
),
)
def adapter() -> MTPortal:
return MTPortal()

View file

@ -0,0 +1,32 @@
"""Montana Secretary of State — portal configuration."""
CONFIG = {
"state": "Montana",
"abbreviation": "MT",
"agency": "Secretary of State",
"portal_name": "SOS",
"portal_url": "https://sosmt.gov",
"name_search_url": "https://biz.sosmt.gov/search",
"portal_login_required": False,
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "2710 N Montana Ave Ste 201",
"city": "Helena",
"state": "MT",
"zip": "59601",
},
"nwra_address": "2710 N Montana Ave Ste 201",
"nwra_city": "Helena",
"nwra_state": "MT",
"nwra_zip": "59601",
"fees": {
"llc": 35,
"corporation": 70,
},
"selectors": {
"search_input": "",
"search_button": "",
"results_table": "",
"no_results": "",
},
}

View file

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

View file

@ -0,0 +1,22 @@
from scripts.formation.base import StatePortal
from .config import CONFIG
class NCPortal(StatePortal):
"""Adapter for the North Carolina Secretary of State business portal."""
CONFIG = CONFIG
def search_name(self, name: str) -> dict:
"""Search for a business name via the NC SOS corporate search."""
return self._web_search(name)
def file_llc(self, payload: dict) -> dict:
"""File Articles of Organization for a North Carolina LLC ($125)."""
payload.setdefault("fee", CONFIG["fees"]["llc"])
return self._submit_filing("llc", payload)
def file_corporation(self, payload: dict) -> dict:
"""File Articles of Incorporation in North Carolina ($125)."""
payload.setdefault("fee", CONFIG["fees"]["corporation"])
return self._submit_filing("corporation", payload)

View file

@ -0,0 +1,20 @@
CONFIG = {
"state": "North Carolina",
"abbreviation": "NC",
"agency": "Secretary of State",
"agency_url": "https://sosnc.gov",
"search_url": "https://sosnc.gov/search/index/corp",
"registered_agent": {
"name": "Northwest Registered Agent",
"street": "8520 Cliff Cameron Dr Ste 106",
"city": "Charlotte",
"state": "NC",
"zip": "28269",
},
"fees": {
"llc": 125,
"corporation": 125,
"annual_report": 200,
},
"notes": "Annual report fee is $200.",
}

View file

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

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