Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
1
scripts/formation/__init__.py
Normal file
1
scripts/formation/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Performance West — 50-State Business Formation Automation
|
||||
388
scripts/formation/base.py
Normal file
388
scripts/formation/base.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""
|
||||
Base class for state Secretary of State portal automation.
|
||||
|
||||
Each state adapter inherits from StatePortal and implements:
|
||||
- search_name() -> Check business name availability
|
||||
- file_llc() -> File LLC Articles of Organization
|
||||
- file_corporation() -> File Articles of Incorporation
|
||||
- check_status() -> Check filing status
|
||||
- download_docs() -> Download filed documents
|
||||
|
||||
All state adapters use Playwright for browser automation.
|
||||
The base class provides shared utilities: screenshot capture, retry logic,
|
||||
CAPTCHA detection, error reporting, and state-specific delay injection
|
||||
(to appear human-paced).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page
|
||||
|
||||
# Undetected Playwright launcher (patchright + stealth fallback). Shared with
|
||||
# the FCC / USAC / BDC compliance filing handlers.
|
||||
from scripts.workers.services.telecom.undetected_browser import (
|
||||
launch_context as _undetected_launch_context,
|
||||
)
|
||||
|
||||
# Keep async_playwright import available for backwards compat (tests may patch
|
||||
# this symbol). When the helper is in use, prefer the shared launcher.
|
||||
try:
|
||||
from patchright.async_api import async_playwright # type: ignore
|
||||
except ImportError:
|
||||
from playwright.async_api import async_playwright # type: ignore
|
||||
|
||||
LOG = logging.getLogger("formation")
|
||||
SCREENSHOTS_DIR = Path(os.getenv("SCREENSHOTS_DIR", "/tmp/formation-screenshots"))
|
||||
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class EntityType(str, Enum):
|
||||
LLC = "llc"
|
||||
CORPORATION = "corporation"
|
||||
S_CORP = "s_corp" # Corp + IRS 2553 election
|
||||
|
||||
|
||||
class FilingStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
NAME_AVAILABLE = "name_available"
|
||||
NAME_UNAVAILABLE = "name_unavailable"
|
||||
SUBMITTED = "submitted"
|
||||
PROCESSING = "processing"
|
||||
FILED = "filed"
|
||||
REJECTED = "rejected"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NameSearchResult:
|
||||
available: bool
|
||||
exact_match: bool = False
|
||||
similar_names: list[str] = field(default_factory=list)
|
||||
state_code: str = ""
|
||||
searched_name: str = ""
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
raw_response: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
name: str
|
||||
address: str
|
||||
city: str
|
||||
state: str
|
||||
zip_code: str
|
||||
title: str = "Member" # Member, Manager, Organizer, Director, etc.
|
||||
ownership_pct: float = 0.0
|
||||
is_organizer: bool = False # Signs the formation docs
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormationOrder:
|
||||
"""All information needed to file a business entity in any state."""
|
||||
order_id: str
|
||||
state_code: str
|
||||
entity_type: EntityType
|
||||
entity_name: str
|
||||
entity_name_alt: str = "" # Backup name if primary unavailable
|
||||
|
||||
# Management
|
||||
management_type: str = "member_managed" # member_managed or manager_managed (LLC)
|
||||
purpose: str = "Any lawful business activity"
|
||||
|
||||
# People
|
||||
members: list[Member] = field(default_factory=list)
|
||||
registered_agent_name: str = "Northwest Registered Agent"
|
||||
registered_agent_address: str = "" # Populated per-state from NW RA
|
||||
|
||||
# Addresses
|
||||
principal_address: str = ""
|
||||
principal_city: str = ""
|
||||
principal_state: str = ""
|
||||
principal_zip: str = ""
|
||||
mailing_address: str = ""
|
||||
mailing_city: str = ""
|
||||
mailing_state: str = ""
|
||||
mailing_zip: str = ""
|
||||
|
||||
# Corp-specific
|
||||
shares_authorized: int = 10000 # Default for corp formation (BC flat fee, no per-share cost)
|
||||
par_value: float = 0.0 # 0 = no par value
|
||||
fiscal_year_end: str = "12/31"
|
||||
|
||||
# Regulatory contact (for CRTC letter — populated from provisioned Canadian identity)
|
||||
regulatory_contact_name: str = "Regulatory Director"
|
||||
regulatory_contact_email: str = "" # regulatory@{.ca domain}
|
||||
regulatory_contact_phone: str = "" # Canadian DID from Flowroute
|
||||
|
||||
# Options
|
||||
expedited: bool = False
|
||||
effective_date: str = "" # Empty = immediate, else future date
|
||||
|
||||
# Payment (Relay virtual debit card — loaded from ERPNext Sensitive ID at runtime)
|
||||
payment_card_number: str = "" # Populated by worker before filing
|
||||
payment_card_exp: str = "" # MM/YY
|
||||
payment_card_cvv: str = ""
|
||||
payment_card_name: str = "Performance West Inc"
|
||||
payment_card_zip: str = "82001" # Cheyenne, WY billing zip
|
||||
|
||||
# Results (populated during filing)
|
||||
status: FilingStatus = FilingStatus.PENDING
|
||||
state_filing_number: str = ""
|
||||
filed_at: str = ""
|
||||
confirmation_number: str = ""
|
||||
documents: list[str] = field(default_factory=list) # File paths
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilingResult:
|
||||
success: bool
|
||||
status: FilingStatus
|
||||
state_code: str
|
||||
entity_name: str
|
||||
filing_number: str = ""
|
||||
confirmation_number: str = ""
|
||||
error_message: str = ""
|
||||
screenshot_path: str = ""
|
||||
documents: list[str] = field(default_factory=list)
|
||||
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class StatePortal(ABC):
|
||||
"""Base class for all state SOS portal automations."""
|
||||
|
||||
STATE_CODE: str = ""
|
||||
STATE_NAME: str = ""
|
||||
PORTAL_NAME: str = ""
|
||||
PORTAL_URL: str = ""
|
||||
SUPPORTS_LLC: bool = True
|
||||
SUPPORTS_CORP: bool = True
|
||||
SUPPORTS_ONLINE_FILING: bool = True
|
||||
SUPPORTS_NAME_SEARCH: bool = True
|
||||
|
||||
# NW Registered Agent address for this state (populated by subclass)
|
||||
NWRA_ADDRESS: str = ""
|
||||
NWRA_CITY: str = ""
|
||||
NWRA_STATE: str = ""
|
||||
NWRA_ZIP: str = ""
|
||||
|
||||
def __init__(self):
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.page: Optional[Page] = None
|
||||
self.log = logging.getLogger(f"formation.{self.STATE_CODE}")
|
||||
|
||||
async def start_browser(self, headless: bool = True) -> Page:
|
||||
"""Launch browser with undetected/stealth settings.
|
||||
|
||||
Uses the shared patchright-based launcher in
|
||||
``scripts/workers/services/telecom/undetected_browser.py`` so that
|
||||
state SoS portals and FCC/USAC filing handlers share one stealth
|
||||
implementation.
|
||||
"""
|
||||
pw = await async_playwright().start()
|
||||
self.browser, self.context = await _undetected_launch_context(
|
||||
pw,
|
||||
headless=headless,
|
||||
timezone_id="America/Denver",
|
||||
)
|
||||
self.page = await self.context.new_page()
|
||||
return self.page
|
||||
|
||||
async def close_browser(self):
|
||||
"""Shut down browser."""
|
||||
if self.context:
|
||||
await self.context.close()
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
|
||||
async def screenshot(self, label: str) -> str:
|
||||
"""Capture screenshot for debugging/audit trail."""
|
||||
if not self.page:
|
||||
return ""
|
||||
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
path = SCREENSHOTS_DIR / f"{self.STATE_CODE}_{label}_{ts}.png"
|
||||
await self.page.screenshot(path=str(path), full_page=True)
|
||||
self.log.info("Screenshot saved: %s", path)
|
||||
return str(path)
|
||||
|
||||
async def human_delay(self, min_s: float = 1.0, max_s: float = 3.0):
|
||||
"""Random delay to appear human."""
|
||||
delay = random.uniform(min_s, max_s)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def type_slowly(self, selector: str, text: str, delay_ms: int = 50):
|
||||
"""Type text character by character with random delays."""
|
||||
if not self.page:
|
||||
return
|
||||
await self.page.click(selector)
|
||||
for char in text:
|
||||
await self.page.type(selector, char, delay=delay_ms + random.randint(0, 30))
|
||||
|
||||
async def safe_click(self, selector: str, timeout: int = 10000):
|
||||
"""Click an element with wait and error handling."""
|
||||
if not self.page:
|
||||
return
|
||||
await self.page.wait_for_selector(selector, timeout=timeout)
|
||||
await self.human_delay(0.3, 0.8)
|
||||
await self.page.click(selector)
|
||||
|
||||
async def detect_captcha(self) -> bool:
|
||||
"""Check if a CAPTCHA is present on the page."""
|
||||
if not self.page:
|
||||
return False
|
||||
captcha_selectors = [
|
||||
"iframe[src*='recaptcha']",
|
||||
"iframe[src*='hcaptcha']",
|
||||
".g-recaptcha",
|
||||
".h-captcha",
|
||||
"#captcha",
|
||||
"[class*='captcha']",
|
||||
"iframe[src*='challenge']",
|
||||
]
|
||||
for sel in captcha_selectors:
|
||||
try:
|
||||
el = await self.page.query_selector(sel)
|
||||
if el:
|
||||
self.log.warning("CAPTCHA detected: %s", sel)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# --- Abstract methods — each state implements these ---
|
||||
|
||||
@abstractmethod
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search for business name availability in this state."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC Articles of Organization."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Articles of Incorporation."""
|
||||
...
|
||||
|
||||
async def file_entity(self, order: FormationOrder) -> FilingResult:
|
||||
"""Route to correct filing method based on entity type."""
|
||||
if order.entity_type in (EntityType.LLC,):
|
||||
return await self.file_llc(order)
|
||||
elif order.entity_type in (EntityType.CORPORATION, EntityType.S_CORP):
|
||||
return await self.file_corporation(order)
|
||||
else:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=f"Unsupported entity type: {order.entity_type}",
|
||||
)
|
||||
|
||||
async def check_status(self, filing_number: str) -> FilingStatus:
|
||||
"""Check the status of a previously submitted filing."""
|
||||
self.log.warning("check_status not implemented for %s", self.STATE_CODE)
|
||||
return FilingStatus.PENDING
|
||||
|
||||
async def enter_payment(
|
||||
self,
|
||||
order: FormationOrder,
|
||||
selectors: dict[str, str],
|
||||
) -> bool:
|
||||
"""Enter Relay virtual debit card payment on a state portal payment form.
|
||||
|
||||
Common payment form selectors (vary by state, passed from config):
|
||||
card_number_field, card_exp_field, card_cvv_field,
|
||||
card_name_field, card_zip_field, submit_payment_btn
|
||||
|
||||
Args:
|
||||
order: FormationOrder with payment card details populated
|
||||
selectors: Dict of CSS selectors for the payment form fields
|
||||
|
||||
Returns:
|
||||
True if payment fields were filled and submitted successfully.
|
||||
"""
|
||||
if not self.page:
|
||||
self.log.error("No browser page open for payment")
|
||||
return False
|
||||
|
||||
if not order.payment_card_number:
|
||||
self.log.error("No payment card number on order — card not loaded from ERPNext")
|
||||
return False
|
||||
|
||||
await self.screenshot("payment_before")
|
||||
self.log.info("Entering payment for %s ($%.2f)",
|
||||
order.entity_name,
|
||||
order.status) # Amount would come from state fee
|
||||
|
||||
try:
|
||||
# Card number
|
||||
if selectors.get("card_number_field"):
|
||||
await self.type_slowly(selectors["card_number_field"], order.payment_card_number, delay_ms=40)
|
||||
await self.human_delay(0.3, 0.6)
|
||||
|
||||
# Expiration (some states split into month/year, some have one field)
|
||||
if selectors.get("card_exp_field"):
|
||||
await self.type_slowly(selectors["card_exp_field"], order.payment_card_exp, delay_ms=40)
|
||||
await self.human_delay(0.2, 0.5)
|
||||
elif selectors.get("card_exp_month_field") and selectors.get("card_exp_year_field"):
|
||||
month, year = order.payment_card_exp.split("/")
|
||||
await self.page.select_option(selectors["card_exp_month_field"], month.strip())
|
||||
await self.page.select_option(selectors["card_exp_year_field"], year.strip())
|
||||
await self.human_delay(0.2, 0.5)
|
||||
|
||||
# CVV
|
||||
if selectors.get("card_cvv_field"):
|
||||
await self.type_slowly(selectors["card_cvv_field"], order.payment_card_cvv, delay_ms=40)
|
||||
await self.human_delay(0.2, 0.5)
|
||||
|
||||
# Name on card
|
||||
if selectors.get("card_name_field"):
|
||||
await self.type_slowly(selectors["card_name_field"], order.payment_card_name, delay_ms=30)
|
||||
await self.human_delay(0.2, 0.5)
|
||||
|
||||
# Billing ZIP
|
||||
if selectors.get("card_zip_field"):
|
||||
await self.type_slowly(selectors["card_zip_field"], order.payment_card_zip, delay_ms=30)
|
||||
await self.human_delay(0.2, 0.5)
|
||||
|
||||
await self.screenshot("payment_filled")
|
||||
|
||||
# Submit payment
|
||||
if selectors.get("submit_payment_btn"):
|
||||
await self.safe_click(selectors["submit_payment_btn"])
|
||||
await self.page.wait_for_load_state("networkidle", timeout=30000)
|
||||
await self.human_delay(2.0, 4.0) # Payment processing delay
|
||||
|
||||
await self.screenshot("payment_after")
|
||||
self.log.info("Payment submitted for %s", order.entity_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log.error("Payment entry failed: %s", e)
|
||||
await self.screenshot("payment_error")
|
||||
return False
|
||||
|
||||
async def download_docs(self, filing_number: str) -> list[str]:
|
||||
"""Download filed documents. Returns list of file paths."""
|
||||
self.log.warning("download_docs not implemented for %s", self.STATE_CODE)
|
||||
return []
|
||||
338
scripts/formation/bulk_download.py
Normal file
338
scripts/formation/bulk_download.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
"""bulk_download.py — Download business entity data from state open data portals.
|
||||
|
||||
Supports:
|
||||
- Socrata SODA API (CO, AK, CT, IL, IA, MI, NY, OR, PA, VT, WA)
|
||||
- SFTP bulk download (FL)
|
||||
- HTTP CSV bulk download (CA, TX)
|
||||
|
||||
Run: python3 scripts/formation/bulk_download.py [--state CO] [--all]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import argparse
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import csv
|
||||
import io
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import psycopg2
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://pw:pw@localhost:5432/performancewest")
|
||||
|
||||
# ── State data source registry ────────────────────────────────────────────────
|
||||
|
||||
SOCRATA_STATES = {
|
||||
# Verified working 2026-04-20
|
||||
"CO": {"url": "https://data.colorado.gov/resource/4ykn-tg5h.json", "name_field": "entityname", "number_field": "entityid", "type_field": "entitytypecode", "status_field": "entitystatus", "date_field": "entityformdate", "formation_state_field": "jurisdictonofformation"},
|
||||
"IA": {"url": "https://data.iowa.gov/resource/ykb6-ywnd.json", "name_field": "entity_name", "number_field": "entity_number", "type_field": "entity_type", "status_field": "entity_status", "date_field": "date_formed", "formation_state_field": "home_state"},
|
||||
"CT": {"url": "https://data.ct.gov/resource/n7gp-d28j.json", "name_field": "name", "number_field": "accountnumber", "type_field": "type", "status_field": "status", "date_field": "date_registration", "formation_state_field": "state_of_formation"},
|
||||
"OR": {"url": "https://data.oregon.gov/resource/tckn-sxa6.json", "name_field": "business_name", "number_field": "registry_number", "type_field": "entity_type", "status_field": "status", "date_field": "registry_date", "formation_state_field": "state_of_origin"},
|
||||
# NY dataset is active entities only (no status field — all are implicitly ACTIVE)
|
||||
# jurisdiction field contains formation state ("New York" for domestic, other state for foreign)
|
||||
"NY": {"url": "https://data.ny.gov/resource/n9v6-gdp6.json", "name_field": "current_entity_name", "number_field": "dos_id", "type_field": "entity_type", "status_field": "", "date_field": "initial_dos_filing_date", "formation_state_field": "jurisdiction", "default_status": "ACTIVE"},
|
||||
# Broken as of 2026-04-20 — dataset IDs need updating (portals reorganized)
|
||||
#
|
||||
# "WA": {"url": "https://data.wa.gov/resource/????.json", ...},
|
||||
# "IL": {"url": "https://data.illinois.gov/resource/????.json", ...},
|
||||
# "PA": {"url": "https://data.pa.gov/resource/????.json", ...},
|
||||
# "MI": {"url": "https://data.michigan.gov/resource/????.json", ...},
|
||||
# "AK": {"url": "https://data.alaska.gov/resource/????.json", ...},
|
||||
# "VT": {"url": "https://data.vermont.gov/resource/????.json", ...},
|
||||
}
|
||||
|
||||
# States with alternative bulk download sources (not Socrata)
|
||||
# These have downloadable CSV/XLSX files from their SOS websites
|
||||
DIRECT_DOWNLOAD_STATES = {
|
||||
# "FL": Florida Sunbiz provides monthly SFTP dump
|
||||
# "CA": California SOS provides daily CSV extract
|
||||
# "TX": Texas Comptroller provides downloadable SOSDirect data
|
||||
# "WY": Wyoming SOS provides CSV export via WyoBiz
|
||||
# "NV": Nevada SilverFlume provides searchable API (not bulk)
|
||||
# "DE": Delaware Division of Corporations — no bulk data (paid API only)
|
||||
}
|
||||
|
||||
# For states without bulk data: use Playwright live search on demand
|
||||
# (slower, ~3-5s per lookup, cached 24h in name_search_cache)
|
||||
# All 52 state adapters support search_name() for on-demand lookups
|
||||
|
||||
# ── Socrata downloader ────────────────────────────────────────────────────────
|
||||
|
||||
def download_socrata(state_code: str, config: dict) -> list[dict]:
|
||||
"""Download all entities from a Socrata SODA API endpoint."""
|
||||
base_url = config["url"]
|
||||
all_records = []
|
||||
offset = 0
|
||||
batch_size = 50000
|
||||
|
||||
while True:
|
||||
url = f"{base_url}?$limit={batch_size}&$offset={offset}&$order=:id"
|
||||
log.info(f" [{state_code}] Fetching offset={offset}...")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
data = json.loads(r.read())
|
||||
except Exception as e:
|
||||
log.error(f" [{state_code}] Socrata error at offset {offset}: {e}")
|
||||
break
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
for record in data:
|
||||
# Extract formation state (where entity was originally incorporated)
|
||||
fs_field = config.get("formation_state_field", "")
|
||||
raw_formation_state = str(record.get(fs_field, "")).strip().upper() if fs_field else ""
|
||||
# Normalize to 2-letter code (some states return full name)
|
||||
formation_state = _normalize_state_code(raw_formation_state) if raw_formation_state else None
|
||||
|
||||
raw_status = str(record.get(config["status_field"], "")).strip() if config.get("status_field") else ""
|
||||
entity = {
|
||||
"entity_name": str(record.get(config["name_field"], "")).strip().upper(),
|
||||
"entity_number": str(record.get(config["number_field"], "")).strip(),
|
||||
"entity_type": _normalize_type(str(record.get(config["type_field"], "")).strip()),
|
||||
"status": _normalize_status(raw_status) if raw_status else config.get("default_status", "ACTIVE"),
|
||||
"formation_date": _parse_date(record.get(config["date_field"])),
|
||||
"formation_state": formation_state,
|
||||
"jurisdiction": f"US_{state_code}",
|
||||
"state": state_code,
|
||||
"registered_agent": str(record.get("registered_agent", record.get("agent_name", ""))).strip() or None,
|
||||
"principal_address": _build_address(record),
|
||||
}
|
||||
if entity["entity_name"] and entity["entity_number"]:
|
||||
all_records.append(entity)
|
||||
|
||||
offset += batch_size
|
||||
if len(data) < batch_size:
|
||||
break
|
||||
|
||||
time.sleep(0.5) # Be respectful to the API
|
||||
|
||||
return all_records
|
||||
|
||||
|
||||
_STATE_NAME_TO_CODE = {
|
||||
"ALABAMA": "AL", "ALASKA": "AK", "ARIZONA": "AZ", "ARKANSAS": "AR",
|
||||
"CALIFORNIA": "CA", "COLORADO": "CO", "CONNECTICUT": "CT", "DELAWARE": "DE",
|
||||
"DISTRICT OF COLUMBIA": "DC", "FLORIDA": "FL", "GEORGIA": "GA", "HAWAII": "HI",
|
||||
"IDAHO": "ID", "ILLINOIS": "IL", "INDIANA": "IN", "IOWA": "IA",
|
||||
"KANSAS": "KS", "KENTUCKY": "KY", "LOUISIANA": "LA", "MAINE": "ME",
|
||||
"MARYLAND": "MD", "MASSACHUSETTS": "MA", "MICHIGAN": "MI", "MINNESOTA": "MN",
|
||||
"MISSISSIPPI": "MS", "MISSOURI": "MO", "MONTANA": "MT", "NEBRASKA": "NE",
|
||||
"NEVADA": "NV", "NEW HAMPSHIRE": "NH", "NEW JERSEY": "NJ", "NEW MEXICO": "NM",
|
||||
"NEW YORK": "NY", "NORTH CAROLINA": "NC", "NORTH DAKOTA": "ND", "OHIO": "OH",
|
||||
"OKLAHOMA": "OK", "OREGON": "OR", "PENNSYLVANIA": "PA", "RHODE ISLAND": "RI",
|
||||
"SOUTH CAROLINA": "SC", "SOUTH DAKOTA": "SD", "TENNESSEE": "TN", "TEXAS": "TX",
|
||||
"UTAH": "UT", "VERMONT": "VT", "VIRGINIA": "VA", "WASHINGTON": "WA",
|
||||
"WEST VIRGINIA": "WV", "WISCONSIN": "WI", "WYOMING": "WY",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_state_code(raw: str) -> Optional[str]:
|
||||
"""Convert full state name or abbreviation to 2-letter code."""
|
||||
raw = raw.strip().upper()
|
||||
if len(raw) == 2 and raw.isalpha():
|
||||
return raw
|
||||
return _STATE_NAME_TO_CODE.get(raw)
|
||||
|
||||
|
||||
def _normalize_type(raw: str) -> str:
|
||||
upper = raw.upper()
|
||||
if "LLC" in upper or "LIMITED LIABILITY" in upper:
|
||||
return "LLC"
|
||||
if "CORP" in upper or "INC" in upper:
|
||||
return "CORPORATION"
|
||||
if "LP" in upper or "LIMITED PARTNERSHIP" in upper:
|
||||
return "LP"
|
||||
if "LLP" in upper:
|
||||
return "LLP"
|
||||
if "NONPROFIT" in upper or "NOT FOR PROFIT" in upper:
|
||||
return "NONPROFIT"
|
||||
return raw.upper()[:50] if raw else None
|
||||
|
||||
|
||||
def _normalize_status(raw: str) -> str:
|
||||
upper = raw.upper()
|
||||
if "ACTIVE" in upper or "GOOD STANDING" in upper or "CURRENT" in upper:
|
||||
return "ACTIVE"
|
||||
if "DISSOLV" in upper or "CANCEL" in upper:
|
||||
return "DISSOLVED"
|
||||
if "SUSPEND" in upper or "REVOK" in upper:
|
||||
return "SUSPENDED"
|
||||
if "DELINQ" in upper or "DEFAULT" in upper:
|
||||
return "DELINQUENT"
|
||||
if "INACTIVE" in upper or "WITHDRAWN" in upper:
|
||||
return "INACTIVE"
|
||||
return raw.upper()[:30] if raw else None
|
||||
|
||||
|
||||
def _parse_date(val) -> str | None:
|
||||
if not val:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
# ISO format
|
||||
if len(s) >= 10 and s[4] == "-":
|
||||
return s[:10]
|
||||
# Socrata floating timestamp: "2020-03-15T00:00:00.000"
|
||||
if "T" in s:
|
||||
return s[:10]
|
||||
return None
|
||||
|
||||
|
||||
def _build_address(record: dict) -> str | None:
|
||||
parts = []
|
||||
for key in ["principal_address", "address", "street_address", "mailing_address",
|
||||
"principal_office_addr", "addr_line1"]:
|
||||
if key in record and record[key]:
|
||||
parts.append(str(record[key]).strip())
|
||||
break
|
||||
for key in ["principal_city", "city"]:
|
||||
if key in record and record[key]:
|
||||
parts.append(str(record[key]).strip())
|
||||
break
|
||||
for key in ["principal_state", "state_province"]:
|
||||
if key in record and record[key]:
|
||||
parts.append(str(record[key]).strip())
|
||||
break
|
||||
for key in ["principal_zip", "zip", "postal_code"]:
|
||||
if key in record and record[key]:
|
||||
parts.append(str(record[key]).strip())
|
||||
break
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
# ── Database upsert ───────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_entities(entities: list[dict], state_code: str) -> int:
|
||||
"""UPSERT entities into entity_cache table. Returns count of upserted rows."""
|
||||
if not entities:
|
||||
return 0
|
||||
|
||||
conn = psycopg2.connect(DB_URL)
|
||||
cur = conn.cursor()
|
||||
count = 0
|
||||
|
||||
try:
|
||||
# Deduplicate by (jurisdiction, entity_number) to avoid ON CONFLICT errors
|
||||
seen_keys: set = set()
|
||||
deduped: list = []
|
||||
for e in entities:
|
||||
key = (e["jurisdiction"], e["entity_number"])
|
||||
if key not in seen_keys:
|
||||
seen_keys.add(key)
|
||||
deduped.append(e)
|
||||
if len(deduped) < len(entities):
|
||||
log.info(f" Deduped: {len(entities)} → {len(deduped)} ({len(entities) - len(deduped)} duplicates removed)")
|
||||
entities = deduped
|
||||
|
||||
for batch_start in range(0, len(entities), 500):
|
||||
batch = entities[batch_start:batch_start + 500]
|
||||
values = []
|
||||
for e in batch:
|
||||
values.append(cur.mogrify(
|
||||
"(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'socrata')",
|
||||
(
|
||||
e["jurisdiction"], e["entity_name"], e["entity_number"],
|
||||
e["entity_type"], e["status"], e["formation_date"],
|
||||
None, # dissolution_date
|
||||
e.get("registered_agent"),
|
||||
e.get("principal_address"),
|
||||
e["state"],
|
||||
e.get("formation_state"),
|
||||
)
|
||||
).decode())
|
||||
|
||||
sql = f"""
|
||||
INSERT INTO entity_cache
|
||||
(jurisdiction, entity_name, entity_number, entity_type, status,
|
||||
formation_date, dissolution_date, registered_agent, principal_address,
|
||||
state, formation_state, source)
|
||||
VALUES {",".join(values)}
|
||||
ON CONFLICT (jurisdiction, entity_number) DO UPDATE SET
|
||||
entity_name = EXCLUDED.entity_name,
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
status = EXCLUDED.status,
|
||||
formation_date = EXCLUDED.formation_date,
|
||||
formation_state = COALESCE(EXCLUDED.formation_state, entity_cache.formation_state),
|
||||
registered_agent = EXCLUDED.registered_agent,
|
||||
principal_address = EXCLUDED.principal_address,
|
||||
last_synced = NOW()
|
||||
"""
|
||||
cur.execute(sql)
|
||||
count += len(batch)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def download_state(state_code: str) -> int:
|
||||
"""Download all entities for a single state. Returns count."""
|
||||
state_code = state_code.upper()
|
||||
|
||||
if state_code in SOCRATA_STATES:
|
||||
log.info(f"Downloading {state_code} via Socrata SODA API...")
|
||||
entities = download_socrata(state_code, SOCRATA_STATES[state_code])
|
||||
else:
|
||||
log.warning(f"{state_code}: no bulk download source configured (Playwright-only)")
|
||||
return 0
|
||||
|
||||
if entities:
|
||||
count = upsert_entities(entities, state_code)
|
||||
log.info(f" [{state_code}] Upserted {count} entities")
|
||||
return count
|
||||
else:
|
||||
log.warning(f" [{state_code}] No entities downloaded")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bulk download business entities from state open data portals")
|
||||
parser.add_argument("--state", type=str, help="Download a single state (2-letter code)")
|
||||
parser.add_argument("--all", action="store_true", help="Download all configured states")
|
||||
parser.add_argument("--list", action="store_true", help="List available states")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
print("Socrata SODA API states:")
|
||||
for code in sorted(SOCRATA_STATES.keys()):
|
||||
print(f" {code}: {SOCRATA_STATES[code]['url']}")
|
||||
return
|
||||
|
||||
if args.state:
|
||||
total = download_state(args.state)
|
||||
log.info(f"Done: {total} entities for {args.state.upper()}")
|
||||
elif args.all:
|
||||
grand_total = 0
|
||||
for code in sorted(SOCRATA_STATES.keys()):
|
||||
total = download_state(code)
|
||||
grand_total += total
|
||||
time.sleep(2) # Pause between states
|
||||
log.info(f"Done: {grand_total} total entities across {len(SOCRATA_STATES)} states")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
444
scripts/formation/document_delivery.py
Normal file
444
scripts/formation/document_delivery.py
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
"""
|
||||
document_delivery.py — Email formation documents to customers.
|
||||
|
||||
Sends a professional HTML email with attached formation documents
|
||||
(Articles of Organization, EIN letter, operating agreement, etc.)
|
||||
and updates the order status to 'delivered'.
|
||||
|
||||
Environment variables:
|
||||
DATABASE_URL PostgreSQL connection string
|
||||
SMTP_HOST SMTP server hostname
|
||||
SMTP_PORT SMTP server port (default: 587)
|
||||
SMTP_USER SMTP username / from address
|
||||
SMTP_PASS SMTP password
|
||||
|
||||
Usage:
|
||||
python -m formation.document_delivery <order_id>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email.mime.application
|
||||
import email.mime.multipart
|
||||
import email.mime.text
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import smtplib
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
from .states import STATES
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||
SMTP_PASS = os.environ.get("SMTP_PASS", "")
|
||||
FROM_NAME = "Performance West"
|
||||
FROM_EMAIL = SMTP_USER or "formations@performancewest.net"
|
||||
|
||||
LOG = logging.getLogger("formation.delivery")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EMAIL_HTML_TEMPLATE = """\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Your Business Has Been Filed</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding: 40px 0;">
|
||||
<tr><td align="center">
|
||||
|
||||
<!-- Header -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#1a1a2e; border-radius:8px 8px 0 0; padding:30px 40px;">
|
||||
<tr><td>
|
||||
<h1 style="color:#ffffff; margin:0; font-size:24px; font-weight:600;">Performance West</h1>
|
||||
<p style="color:#a0a0c0; margin:5px 0 0; font-size:14px;">Business Formation Services</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Body -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; padding:40px;">
|
||||
<tr><td>
|
||||
|
||||
<p style="font-size:16px; color:#333;">Dear {customer_name},</p>
|
||||
|
||||
<p style="font-size:16px; color:#333;">
|
||||
Great news — your <strong>{entity_type}</strong> has been successfully
|
||||
filed with the state of <strong>{state_name}</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Filing details box -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4ff; border-radius:8px; padding:24px; margin:24px 0;">
|
||||
<tr><td>
|
||||
<table width="100%" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td style="font-size:14px; color:#666; width:180px;">Entity Name</td>
|
||||
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{entity_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px; color:#666;">State</td>
|
||||
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{state_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px; color:#666;">Filing Number</td>
|
||||
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{filing_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px; color:#666;">Confirmation Number</td>
|
||||
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{confirmation_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px; color:#666;">Filed Date</td>
|
||||
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{filed_date}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="font-size:16px; color:#333;">
|
||||
Your formation documents are attached to this email.
|
||||
</p>
|
||||
|
||||
<!-- Next steps -->
|
||||
<h2 style="font-size:18px; color:#1a1a2e; margin:32px 0 16px;">Recommended Next Steps</h2>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding:8px 0; font-size:15px; color:#333;">
|
||||
<strong>1. Obtain an EIN</strong> — Apply for an Employer Identification Number
|
||||
from the IRS. This is required to open a business bank account and file taxes.
|
||||
{ein_note}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0; font-size:15px; color:#333;">
|
||||
<strong>2. Operating Agreement</strong> — Prepare and sign an operating agreement
|
||||
for your {entity_type}. This document outlines ownership, management structure,
|
||||
and member responsibilities.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0; font-size:15px; color:#333;">
|
||||
<strong>3. Open a Business Bank Account</strong> — Keep personal and business
|
||||
finances separate. You'll need your Articles of Organization, EIN, and
|
||||
operating agreement.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0; font-size:15px; color:#333;">
|
||||
<strong>4. Business Licenses & Permits</strong> — Check your local
|
||||
city/county requirements for any additional licenses or permits.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0; font-size:15px; color:#333;">
|
||||
<strong>5. Annual Reports</strong> — Most states require an annual or biennial
|
||||
report. We'll send you a reminder when yours is due.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr style="border:none; border-top:1px solid #e0e0e0; margin:32px 0;" />
|
||||
|
||||
<p style="font-size:14px; color:#666;">
|
||||
If you have any questions about your filing or need additional services,
|
||||
don't hesitate to reach out.
|
||||
</p>
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:24px 40px;">
|
||||
<tr><td>
|
||||
<p style="font-size:13px; color:#999; margin:0;">
|
||||
Performance West · Business Formation & Compliance Services
|
||||
</p>
|
||||
<p style="font-size:13px; color:#999; margin:4px 0 0;">
|
||||
Email: formations@performancewest.net · Phone: (307) 316-5620
|
||||
</p>
|
||||
<p style="font-size:12px; color:#bbb; margin:12px 0 0;">
|
||||
This email and any attachments are intended solely for the named recipient.
|
||||
If you received this in error, please delete it and notify the sender.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_connection():
|
||||
if not DATABASE_URL:
|
||||
raise RuntimeError("DATABASE_URL environment variable is not set.")
|
||||
return psycopg2.connect(DATABASE_URL)
|
||||
|
||||
|
||||
def _fetch_order(conn, order_id: str) -> dict | None:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _mark_delivered(conn, order_id: str):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE formation_orders
|
||||
SET status = 'delivered',
|
||||
delivered_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE order_id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email sending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_email(
|
||||
customer_email: str,
|
||||
customer_name: str,
|
||||
entity_name: str,
|
||||
entity_type: str,
|
||||
state_code: str,
|
||||
filing_number: str,
|
||||
confirmation_number: str,
|
||||
filed_date: str,
|
||||
documents: list[str],
|
||||
ein: str = "",
|
||||
) -> email.mime.multipart.MIMEMultipart:
|
||||
"""Build the MIME email with HTML body and document attachments."""
|
||||
state_name = STATES.get(state_code.upper(), {}).get("name", state_code)
|
||||
|
||||
# Entity type display name
|
||||
type_display = {
|
||||
"llc": "LLC",
|
||||
"corporation": "Corporation",
|
||||
"s_corp": "S Corporation",
|
||||
}.get(entity_type.lower(), entity_type)
|
||||
|
||||
ein_note = ""
|
||||
if ein:
|
||||
ein_note = f"<br/><em>Your EIN ({ein}) has already been obtained and is included in your documents.</em>"
|
||||
|
||||
html_body = EMAIL_HTML_TEMPLATE.format(
|
||||
customer_name=customer_name,
|
||||
entity_type=type_display,
|
||||
entity_name=entity_name,
|
||||
state_name=state_name,
|
||||
filing_number=filing_number or "Pending",
|
||||
confirmation_number=confirmation_number or "N/A",
|
||||
filed_date=filed_date or "N/A",
|
||||
ein_note=ein_note,
|
||||
)
|
||||
|
||||
msg = email.mime.multipart.MIMEMultipart("mixed")
|
||||
msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>"
|
||||
msg["To"] = customer_email
|
||||
msg["Subject"] = f"Your {type_display} Has Been Filed — {entity_name}"
|
||||
msg["Reply-To"] = FROM_EMAIL
|
||||
|
||||
# HTML body
|
||||
html_part = email.mime.text.MIMEText(html_body, "html", "utf-8")
|
||||
msg.attach(html_part)
|
||||
|
||||
# Attach documents
|
||||
for doc_path in documents:
|
||||
path = Path(doc_path)
|
||||
if not path.exists():
|
||||
LOG.warning("Document not found, skipping: %s", doc_path)
|
||||
continue
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(path))
|
||||
if content_type is None:
|
||||
content_type = "application/octet-stream"
|
||||
maintype, subtype = content_type.split("/", 1)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
attachment = email.mime.application.MIMEApplication(f.read(), _subtype=subtype)
|
||||
attachment.add_header(
|
||||
"Content-Disposition",
|
||||
"attachment",
|
||||
filename=path.name,
|
||||
)
|
||||
msg.attach(attachment)
|
||||
LOG.info("Attached: %s (%s)", path.name, content_type)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def send_delivery_email(
|
||||
order_id: str,
|
||||
customer_email: str,
|
||||
customer_name: str,
|
||||
documents: list[str],
|
||||
) -> bool:
|
||||
"""
|
||||
Send formation documents to a customer and update order status.
|
||||
|
||||
Args:
|
||||
order_id: The formation order ID.
|
||||
customer_email: Customer's email address.
|
||||
customer_name: Customer's display name.
|
||||
documents: List of file paths to attach.
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise.
|
||||
"""
|
||||
if not SMTP_HOST:
|
||||
LOG.error("SMTP_HOST not configured — cannot send email.")
|
||||
return False
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
order = _fetch_order(conn, order_id)
|
||||
if not order:
|
||||
LOG.error("Order not found: %s", order_id)
|
||||
return False
|
||||
|
||||
entity_name = order.get("entity_name", "")
|
||||
entity_type = order.get("entity_type", "llc")
|
||||
state_code = order.get("state_code", "")
|
||||
filing_number = order.get("filing_number", "")
|
||||
confirmation_number = order.get("confirmation_number", "")
|
||||
filed_at = order.get("filed_at")
|
||||
ein = order.get("ein", "") or ""
|
||||
|
||||
filed_date = ""
|
||||
if filed_at:
|
||||
if isinstance(filed_at, str):
|
||||
filed_date = filed_at[:10]
|
||||
elif isinstance(filed_at, datetime):
|
||||
filed_date = filed_at.strftime("%Y-%m-%d")
|
||||
|
||||
msg = _build_email(
|
||||
customer_email=customer_email,
|
||||
customer_name=customer_name,
|
||||
entity_name=entity_name,
|
||||
entity_type=entity_type,
|
||||
state_code=state_code,
|
||||
filing_number=filing_number,
|
||||
confirmation_number=confirmation_number,
|
||||
filed_date=filed_date,
|
||||
documents=documents,
|
||||
ein=ein,
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"Sending delivery email to %s for order %s (%s)...",
|
||||
customer_email,
|
||||
order_id,
|
||||
entity_name,
|
||||
)
|
||||
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
|
||||
smtp.ehlo()
|
||||
if SMTP_PORT != 25:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if SMTP_USER and SMTP_PASS:
|
||||
smtp.login(SMTP_USER, SMTP_PASS)
|
||||
smtp.send_message(msg)
|
||||
|
||||
LOG.info("Email sent successfully to %s", customer_email)
|
||||
|
||||
# Mark order as delivered
|
||||
_mark_delivered(conn, order_id)
|
||||
LOG.info("Order %s marked as delivered", order_id)
|
||||
return True
|
||||
|
||||
except smtplib.SMTPException as exc:
|
||||
LOG.error("SMTP error sending to %s: %s", customer_email, exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
LOG.error("Failed to send delivery email: %s", exc, exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point: deliver documents for a specific order."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m formation.document_delivery <order_id>")
|
||||
print()
|
||||
print("Fetches order details from the database, builds a delivery email,")
|
||||
print("and sends it with attached documents.")
|
||||
sys.exit(1)
|
||||
|
||||
order_id = sys.argv[1]
|
||||
|
||||
if not DATABASE_URL:
|
||||
print("Error: DATABASE_URL not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not SMTP_HOST:
|
||||
print("Error: SMTP_HOST not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
order = _fetch_order(conn, order_id)
|
||||
if not order:
|
||||
print(f"Error: Order {order_id} not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
customer_email = order.get("customer_email", "")
|
||||
customer_name = order.get("customer_name", "")
|
||||
|
||||
if not customer_email:
|
||||
print(f"Error: No customer_email on order {order_id}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Gather document paths
|
||||
docs_raw = order.get("documents")
|
||||
if isinstance(docs_raw, str):
|
||||
docs_raw = json.loads(docs_raw)
|
||||
documents = docs_raw or []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
success = send_delivery_email(order_id, customer_email, customer_name, documents)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
666
scripts/formation/ein_worker.py
Normal file
666
scripts/formation/ein_worker.py
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
"""
|
||||
ein_worker.py — IRS EIN (Employer Identification Number) obtainment via the
|
||||
IRS online application at https://sa.www4.irs.gov/modiein/individual/index.jsp
|
||||
|
||||
Uses Playwright to fill out the SS-4 equivalent online form and extracts the
|
||||
assigned EIN from the confirmation page.
|
||||
|
||||
IMPORTANT: IRS online EIN is only available Mon–Fri, 7:00 AM – 10:00 PM ET.
|
||||
|
||||
Environment variables:
|
||||
DATABASE_URL PostgreSQL connection string (optional, for order updates)
|
||||
|
||||
Usage:
|
||||
# Standalone — obtain EIN for an order in the database
|
||||
python -m formation.ein_worker <order_id>
|
||||
|
||||
# Called programmatically from formation_worker
|
||||
from formation.ein_worker import obtain_ein
|
||||
result = await obtain_ein(order)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from playwright.async_api import async_playwright, Page
|
||||
|
||||
from .base import EntityType, FormationOrder, Member
|
||||
|
||||
LOG = logging.getLogger("formation.ein")
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
||||
IRS_EIN_URL = "https://sa.www4.irs.gov/modiein/individual/index.jsp"
|
||||
SCREENSHOTS_DIR = Path(os.getenv("SCREENSHOTS_DIR", "/tmp/formation-screenshots"))
|
||||
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class EINResult:
|
||||
success: bool
|
||||
ein: str = ""
|
||||
confirmation_pdf: str = "" # Path to PDF screenshot
|
||||
error_message: str = ""
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Availability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ET = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def is_irs_available() -> bool:
|
||||
"""
|
||||
Check if the IRS online EIN application is currently available.
|
||||
Available Mon–Fri, 7:00 AM – 10:00 PM Eastern Time.
|
||||
"""
|
||||
now_et = datetime.now(ET)
|
||||
weekday = now_et.weekday() # 0=Monday, 6=Sunday
|
||||
hour = now_et.hour
|
||||
|
||||
if weekday >= 5: # Saturday or Sunday
|
||||
return False
|
||||
if hour < 7 or hour >= 22: # Before 7 AM or after 10 PM
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def next_available_time() -> datetime:
|
||||
"""Return the next datetime (ET) when the IRS EIN service will be available."""
|
||||
now_et = datetime.now(ET)
|
||||
|
||||
# If currently available, return now
|
||||
if is_irs_available():
|
||||
return now_et
|
||||
|
||||
# Find next available slot
|
||||
candidate = now_et.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
if candidate <= now_et:
|
||||
# Move to next day
|
||||
from datetime import timedelta
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
# Skip weekends
|
||||
while candidate.weekday() >= 5:
|
||||
from datetime import timedelta
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: responsible party (first member / organizer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_responsible_party(order: FormationOrder) -> Member | None:
|
||||
"""Get the responsible party for the EIN application."""
|
||||
# Prefer the organizer
|
||||
for m in order.members:
|
||||
if m.is_organizer:
|
||||
return m
|
||||
# Fall back to first member
|
||||
return order.members[0] if order.members else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core EIN automation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def obtain_ein(order: FormationOrder) -> EINResult:
|
||||
"""
|
||||
Obtain an EIN from the IRS online application for the given order.
|
||||
|
||||
Navigates the IRS EIN Assistant, fills out entity information, responsible
|
||||
party details, and extracts the assigned EIN from the confirmation page.
|
||||
|
||||
Args:
|
||||
order: FormationOrder with entity and member details.
|
||||
|
||||
Returns:
|
||||
EINResult with the assigned EIN or error information.
|
||||
"""
|
||||
# Check availability
|
||||
if not is_irs_available():
|
||||
next_time = next_available_time()
|
||||
return EINResult(
|
||||
success=False,
|
||||
error_message=(
|
||||
f"IRS online EIN application is not currently available. "
|
||||
f"Hours: Mon–Fri 7 AM – 10 PM ET. "
|
||||
f"Next available: {next_time.strftime('%A %B %d, %Y at %I:%M %p ET')}"
|
||||
),
|
||||
)
|
||||
|
||||
responsible_party = _get_responsible_party(order)
|
||||
if not responsible_party:
|
||||
return EINResult(
|
||||
success=False,
|
||||
error_message="No members/responsible party found on order.",
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"[%s] Starting EIN application for %s (%s)",
|
||||
order.order_id,
|
||||
order.entity_name,
|
||||
order.state_code,
|
||||
)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
browser = await pw.chromium.launch(
|
||||
headless=True,
|
||||
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 900},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/123.0.0.0 Safari/537.36"
|
||||
),
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
)
|
||||
await context.add_init_script(
|
||||
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
async def _screenshot(label: str) -> str:
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
path = SCREENSHOTS_DIR / f"ein_{order.order_id}_{label}_{ts}.png"
|
||||
await page.screenshot(path=str(path), full_page=True)
|
||||
LOG.info("Screenshot: %s", path)
|
||||
return str(path)
|
||||
|
||||
async def _delay(min_s: float = 1.0, max_s: float = 3.0):
|
||||
import random
|
||||
await asyncio.sleep(random.uniform(min_s, max_s))
|
||||
|
||||
try:
|
||||
# Step 1: Navigate to IRS EIN Assistant
|
||||
LOG.info("[%s] Navigating to IRS EIN Assistant...", order.order_id)
|
||||
await page.goto(IRS_EIN_URL, wait_until="networkidle", timeout=30000)
|
||||
await _delay(2, 4)
|
||||
await _screenshot("01_landing")
|
||||
|
||||
# Step 2: Begin application — click "Begin Application" or "Apply Online Now"
|
||||
begin_selectors = [
|
||||
"input[value*='Begin Application']",
|
||||
"a:has-text('Begin Application')",
|
||||
"input[value*='Apply']",
|
||||
"button:has-text('Begin')",
|
||||
]
|
||||
for sel in begin_selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
await el.click()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
await _delay(2, 3)
|
||||
|
||||
# Step 3: Select entity type
|
||||
LOG.info("[%s] Selecting entity type...", order.order_id)
|
||||
if order.entity_type == EntityType.LLC:
|
||||
# Select "Limited Liability Company (LLC)"
|
||||
llc_selectors = [
|
||||
"input[value*='LLC']",
|
||||
"input[value*='limited liability']",
|
||||
"label:has-text('Limited Liability Company')",
|
||||
"input[type='radio'][id*='llc']",
|
||||
]
|
||||
for sel in llc_selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
await el.click()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
elif order.entity_type in (EntityType.CORPORATION, EntityType.S_CORP):
|
||||
corp_selectors = [
|
||||
"input[value*='Corporation']",
|
||||
"label:has-text('Corporation')",
|
||||
"input[type='radio'][id*='corp']",
|
||||
]
|
||||
for sel in corp_selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
await el.click()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
await _delay(1, 2)
|
||||
|
||||
# Click Continue/Next
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
await _screenshot("02_entity_type")
|
||||
|
||||
# Step 4: Number of members (for LLC)
|
||||
if order.entity_type == EntityType.LLC:
|
||||
member_count = len(order.members)
|
||||
if member_count <= 1:
|
||||
# Single-member LLC
|
||||
try:
|
||||
await page.click("input[value*='1'], input[value*='single']")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Multi-member LLC
|
||||
try:
|
||||
await page.click("input[value*='multi'], input[value*='More']")
|
||||
except Exception:
|
||||
pass
|
||||
await _delay(1, 2)
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
|
||||
# Step 5: State of formation
|
||||
LOG.info("[%s] Selecting state: %s", order.order_id, order.state_code)
|
||||
state_select = await page.query_selector("select[name*='state'], select[id*='state']")
|
||||
if state_select:
|
||||
from .states import STATES
|
||||
state_name = STATES.get(order.state_code.upper(), {}).get("name", order.state_code)
|
||||
await state_select.select_option(label=state_name)
|
||||
await _delay(1, 2)
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
await _screenshot("03_state")
|
||||
|
||||
# Step 6: Reason for applying — "Started new business"
|
||||
LOG.info("[%s] Selecting reason for applying...", order.order_id)
|
||||
reason_selectors = [
|
||||
"input[value*='Started']",
|
||||
"input[value*='new business']",
|
||||
"label:has-text('Started new business')",
|
||||
"input[type='radio']:first-of-type",
|
||||
]
|
||||
for sel in reason_selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
await el.click()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
await _delay(1, 2)
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
await _screenshot("04_reason")
|
||||
|
||||
# Step 7: Entity information — name, address
|
||||
LOG.info("[%s] Filling entity information...", order.order_id)
|
||||
await _fill_field(page, "name", order.entity_name)
|
||||
await _fill_field(page, "trade", order.entity_name) # DBA if asked
|
||||
await _fill_field(page, "address", order.principal_address or responsible_party.address)
|
||||
await _fill_field(page, "city", order.principal_city or responsible_party.city)
|
||||
await _fill_field(page, "zip", order.principal_zip or responsible_party.zip_code)
|
||||
|
||||
# State dropdown for address
|
||||
addr_state = order.principal_state or responsible_party.state
|
||||
addr_state_selects = await page.query_selector_all("select")
|
||||
for sel_el in addr_state_selects:
|
||||
name_attr = await sel_el.get_attribute("name") or ""
|
||||
id_attr = await sel_el.get_attribute("id") or ""
|
||||
if "state" in name_attr.lower() or "state" in id_attr.lower():
|
||||
try:
|
||||
await sel_el.select_option(value=addr_state)
|
||||
except Exception:
|
||||
try:
|
||||
from .states import STATES as _S
|
||||
sn = _S.get(addr_state.upper(), {}).get("name", addr_state)
|
||||
await sel_el.select_option(label=sn)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
await _delay(1, 2)
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
await _screenshot("05_entity_info")
|
||||
|
||||
# Step 8: Responsible party information
|
||||
LOG.info("[%s] Filling responsible party: %s", order.order_id, responsible_party.name)
|
||||
name_parts = responsible_party.name.split(None, 1)
|
||||
first_name = name_parts[0] if name_parts else ""
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else ""
|
||||
|
||||
await _fill_field(page, "first", first_name)
|
||||
await _fill_field(page, "last", last_name)
|
||||
|
||||
# SSN/ITIN — these would be provided securely; placeholder for the field
|
||||
# In production, SSN is passed through secure order data (not stored in plain text)
|
||||
ssn = getattr(order, "_responsible_party_ssn", "")
|
||||
if ssn:
|
||||
ssn_fields = await page.query_selector_all("input[type='text'][maxlength='3'], input[type='text'][maxlength='2'], input[type='text'][maxlength='4']")
|
||||
ssn_digits = re.sub(r"\D", "", ssn)
|
||||
if len(ssn_digits) == 9 and len(ssn_fields) >= 3:
|
||||
await ssn_fields[0].fill(ssn_digits[:3])
|
||||
await _delay(0.3, 0.6)
|
||||
await ssn_fields[1].fill(ssn_digits[3:5])
|
||||
await _delay(0.3, 0.6)
|
||||
await ssn_fields[2].fill(ssn_digits[5:])
|
||||
|
||||
await _delay(1, 2)
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
await _screenshot("06_responsible_party")
|
||||
|
||||
# Step 9: Additional questions — date started, fiscal year, etc.
|
||||
LOG.info("[%s] Filling additional details...", order.order_id)
|
||||
today_str = datetime.now().strftime("%m/%d/%Y")
|
||||
await _fill_field(page, "date", order.effective_date or today_str)
|
||||
await _fill_field(page, "closing", order.fiscal_year_end or "December")
|
||||
|
||||
# Number of employees expected (select "0" or "No employees planned")
|
||||
await _fill_field(page, "employee", "0")
|
||||
|
||||
await _delay(1, 2)
|
||||
await _click_continue(page)
|
||||
await _delay(2, 3)
|
||||
await _screenshot("07_additional")
|
||||
|
||||
# Step 10: Review and submit
|
||||
LOG.info("[%s] Reviewing and submitting application...", order.order_id)
|
||||
await _screenshot("08_review")
|
||||
submit_selectors = [
|
||||
"input[value*='Submit']",
|
||||
"button:has-text('Submit')",
|
||||
"input[type='submit']",
|
||||
]
|
||||
for sel in submit_selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
await el.click()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
await _delay(3, 5)
|
||||
await _screenshot("09_submitted")
|
||||
|
||||
# Step 11: Extract EIN from confirmation page
|
||||
LOG.info("[%s] Extracting EIN from confirmation...", order.order_id)
|
||||
page_text = await page.inner_text("body")
|
||||
|
||||
# EIN format: XX-XXXXXXX
|
||||
ein_match = re.search(r"\b(\d{2}-\d{7})\b", page_text)
|
||||
if not ein_match:
|
||||
# Try without hyphen
|
||||
ein_match = re.search(r"EIN[:\s]*(\d{9})", page_text, re.IGNORECASE)
|
||||
|
||||
if ein_match:
|
||||
ein = ein_match.group(1)
|
||||
# Normalize to XX-XXXXXXX format
|
||||
if "-" not in ein and len(ein) == 9:
|
||||
ein = f"{ein[:2]}-{ein[2:]}"
|
||||
LOG.info("[%s] EIN obtained: %s", order.order_id, ein)
|
||||
else:
|
||||
LOG.error("[%s] Could not extract EIN from confirmation page", order.order_id)
|
||||
await _screenshot("09_no_ein_found")
|
||||
return EINResult(
|
||||
success=False,
|
||||
error_message="Could not extract EIN from IRS confirmation page.",
|
||||
confirmation_pdf=await _save_confirmation_pdf(page, order.order_id),
|
||||
)
|
||||
|
||||
# Save confirmation as PDF
|
||||
confirmation_pdf = await _save_confirmation_pdf(page, order.order_id)
|
||||
await _screenshot("10_confirmation")
|
||||
|
||||
return EINResult(
|
||||
success=True,
|
||||
ein=ein,
|
||||
confirmation_pdf=confirmation_pdf,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] EIN application failed: %s", order.order_id, exc, exc_info=True)
|
||||
try:
|
||||
await _screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
return EINResult(
|
||||
success=False,
|
||||
error_message=str(exc),
|
||||
)
|
||||
finally:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page interaction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _fill_field(page: Page, name_hint: str, value: str):
|
||||
"""
|
||||
Attempt to fill a form field matching a name/id hint.
|
||||
Tries multiple selector strategies.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
|
||||
selectors = [
|
||||
f"input[name*='{name_hint}' i]",
|
||||
f"input[id*='{name_hint}' i]",
|
||||
f"textarea[name*='{name_hint}' i]",
|
||||
f"select[name*='{name_hint}' i]",
|
||||
]
|
||||
for sel in selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el:
|
||||
tag = await el.evaluate("e => e.tagName.toLowerCase()")
|
||||
if tag == "select":
|
||||
try:
|
||||
await el.select_option(label=value)
|
||||
except Exception:
|
||||
await el.select_option(value=value)
|
||||
else:
|
||||
await el.fill(value)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def _click_continue(page: Page):
|
||||
"""Click the Continue/Next/Submit button on the current IRS page."""
|
||||
selectors = [
|
||||
"input[value='Continue']",
|
||||
"input[value='Next']",
|
||||
"input[value*='Continue']",
|
||||
"button:has-text('Continue')",
|
||||
"button:has-text('Next')",
|
||||
"input[type='submit']",
|
||||
]
|
||||
for sel in selectors:
|
||||
try:
|
||||
el = await page.query_selector(sel)
|
||||
if el and await el.is_visible():
|
||||
await el.click()
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def _save_confirmation_pdf(page: Page, order_id: str) -> str:
|
||||
"""Save the current page as a PDF screenshot for records."""
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
output_dir = Path(f"/tmp/formations/{order_id}")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
pdf_path = output_dir / f"ein_confirmation_{ts}.pdf"
|
||||
try:
|
||||
await page.pdf(path=str(pdf_path))
|
||||
LOG.info("EIN confirmation PDF saved: %s", pdf_path)
|
||||
except Exception:
|
||||
# PDF generation only works in headless Chromium; fall back to screenshot
|
||||
png_path = output_dir / f"ein_confirmation_{ts}.png"
|
||||
await page.screenshot(path=str(png_path), full_page=True)
|
||||
LOG.info("EIN confirmation screenshot saved (PDF fallback): %s", png_path)
|
||||
return str(png_path)
|
||||
return str(pdf_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _update_order_ein(order_id: str, ein: str, confirmation_pdf: str):
|
||||
"""Update the formation_orders table with the obtained EIN."""
|
||||
if not DATABASE_URL:
|
||||
LOG.warning("DATABASE_URL not set — skipping order update for EIN")
|
||||
return
|
||||
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE formation_orders
|
||||
SET ein = %s,
|
||||
ein_confirmation = %s,
|
||||
updated_at = NOW()
|
||||
WHERE order_id = %s
|
||||
""",
|
||||
(ein, confirmation_pdf, order_id),
|
||||
)
|
||||
conn.commit()
|
||||
LOG.info("Updated order %s with EIN %s", order_id, ein)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _main_standalone(order_id: str):
|
||||
"""Fetch order from DB and obtain EIN."""
|
||||
if not DATABASE_URL:
|
||||
print("Error: DATABASE_URL not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
print(f"Error: Order {order_id} not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build FormationOrder from row
|
||||
members_raw = row.get("members")
|
||||
if isinstance(members_raw, str):
|
||||
members_raw = json.loads(members_raw)
|
||||
elif members_raw is None:
|
||||
members_raw = []
|
||||
|
||||
members = [
|
||||
Member(
|
||||
name=m.get("name", ""),
|
||||
address=m.get("address", ""),
|
||||
city=m.get("city", ""),
|
||||
state=m.get("state", ""),
|
||||
zip_code=m.get("zip_code", ""),
|
||||
title=m.get("title", "Member"),
|
||||
ownership_pct=float(m.get("ownership_pct", 0)),
|
||||
is_organizer=bool(m.get("is_organizer", False)),
|
||||
)
|
||||
for m in members_raw
|
||||
]
|
||||
|
||||
try:
|
||||
entity_type = EntityType(row.get("entity_type", "llc"))
|
||||
except ValueError:
|
||||
entity_type = EntityType.LLC
|
||||
|
||||
order = FormationOrder(
|
||||
order_id=str(row["order_id"]),
|
||||
state_code=row.get("state_code", ""),
|
||||
entity_type=entity_type,
|
||||
entity_name=row.get("entity_name", ""),
|
||||
members=members,
|
||||
principal_address=row.get("principal_address", ""),
|
||||
principal_city=row.get("principal_city", ""),
|
||||
principal_state=row.get("principal_state", ""),
|
||||
principal_zip=row.get("principal_zip", ""),
|
||||
fiscal_year_end=row.get("fiscal_year_end", "12/31"),
|
||||
effective_date=row.get("effective_date", "") or "",
|
||||
)
|
||||
|
||||
# Check availability first
|
||||
if not is_irs_available():
|
||||
next_time = next_available_time()
|
||||
print(
|
||||
f"IRS EIN online service is currently unavailable.\n"
|
||||
f"Hours: Mon–Fri, 7:00 AM – 10:00 PM ET\n"
|
||||
f"Next available: {next_time.strftime('%A %B %d, %Y at %I:%M %p ET')}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
result = await obtain_ein(order)
|
||||
|
||||
if result.success:
|
||||
print(f"EIN obtained: {result.ein}")
|
||||
print(f"Confirmation: {result.confirmation_pdf}")
|
||||
_update_order_ein(order.order_id, result.ein, result.confirmation_pdf)
|
||||
else:
|
||||
print(f"EIN application failed: {result.error_message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m formation.ein_worker <order_id>")
|
||||
print()
|
||||
print("Obtains an EIN from the IRS online application for the given order.")
|
||||
print()
|
||||
print("Note: IRS online EIN is only available Mon–Fri, 7 AM – 10 PM ET.")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(_main_standalone(sys.argv[1]))
|
||||
563
scripts/formation/formation_worker.py
Normal file
563
scripts/formation/formation_worker.py
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
"""
|
||||
formation_worker.py — Order queue processor for business formation filings.
|
||||
|
||||
Polls the PostgreSQL `formation_orders` table for new orders and processes them
|
||||
through the appropriate state adapter. Designed to run as a long-lived daemon
|
||||
with single-instance locking.
|
||||
|
||||
Features:
|
||||
- Polls for orders with status='received' every 60 seconds
|
||||
- Configurable human-paced delays between orders
|
||||
- Single-instance locking via fcntl.flock
|
||||
- Structured logging to ~/logs/formation-worker.log
|
||||
- ERPNext Issue creation on errors
|
||||
|
||||
Environment variables:
|
||||
DATABASE_URL PostgreSQL connection string
|
||||
FORMATION_DELAY_MIN Minimum delay between orders in minutes (default: 30)
|
||||
FORMATION_DELAY_MAX Maximum delay between orders in minutes (default: 120)
|
||||
|
||||
Usage:
|
||||
python -m formation.formation_worker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
from .base import (
|
||||
EntityType,
|
||||
FilingResult,
|
||||
FilingStatus,
|
||||
FormationOrder,
|
||||
Member,
|
||||
NameSearchResult,
|
||||
)
|
||||
from .states import get_adapter, STATES
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
||||
POLL_INTERVAL_SECONDS = 60
|
||||
DELAY_MIN_MINUTES = int(os.environ.get("FORMATION_DELAY_MIN", "30"))
|
||||
DELAY_MAX_MINUTES = int(os.environ.get("FORMATION_DELAY_MAX", "120"))
|
||||
LOCK_FILE = "/tmp/formation-worker.lock"
|
||||
LOG_DIR = Path.home() / "logs"
|
||||
LOG_FILE = LOG_DIR / "formation-worker.log"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
LOG = logging.getLogger("formation.worker")
|
||||
|
||||
_file_handler = logging.FileHandler(str(LOG_FILE))
|
||||
_file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
)
|
||||
_stream_handler = logging.StreamHandler(sys.stdout)
|
||||
_stream_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
)
|
||||
|
||||
LOG.addHandler(_file_handler)
|
||||
LOG.addHandler(_stream_handler)
|
||||
LOG.setLevel(logging.INFO)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alerting (ERPNext Issues)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _alert_error(order_id: str, state_code: str, error: str, detail: str = ""):
|
||||
"""Create an ERPNext Issue for a formation processing error."""
|
||||
try:
|
||||
# Import the shared alert module (one level up)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from alert import alert_account_broken
|
||||
|
||||
alert_account_broken(
|
||||
monitor="formation-worker",
|
||||
platform=f"SOS-{state_code}",
|
||||
error=f"Formation order {order_id} failed: {error}",
|
||||
detail=detail,
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.warning("Failed to send alert for order %s: %s", order_id, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""Open a PostgreSQL connection from DATABASE_URL."""
|
||||
if not DATABASE_URL:
|
||||
raise RuntimeError(
|
||||
"DATABASE_URL environment variable is not set. "
|
||||
"Expected format: postgresql://user:pass@host:5432/dbname"
|
||||
)
|
||||
return psycopg2.connect(DATABASE_URL)
|
||||
|
||||
|
||||
def _fetch_pending_orders(conn) -> list[dict]:
|
||||
"""Fetch all orders with status='received', oldest first."""
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM formation_orders
|
||||
WHERE status = 'received'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _update_order_status(
|
||||
conn,
|
||||
order_id: str,
|
||||
status: str,
|
||||
*,
|
||||
filing_number: str = "",
|
||||
confirmation_number: str = "",
|
||||
error_message: str = "",
|
||||
screenshots: list[str] | None = None,
|
||||
documents: list[str] | None = None,
|
||||
ein: str = "",
|
||||
):
|
||||
"""Update an order's status and related fields."""
|
||||
fields = ["status = %s", "updated_at = NOW()"]
|
||||
values: list = [status]
|
||||
|
||||
if filing_number:
|
||||
fields.append("filing_number = %s")
|
||||
values.append(filing_number)
|
||||
if confirmation_number:
|
||||
fields.append("confirmation_number = %s")
|
||||
values.append(confirmation_number)
|
||||
if error_message:
|
||||
fields.append("error_message = %s")
|
||||
values.append(error_message)
|
||||
if screenshots is not None:
|
||||
fields.append("screenshots = %s")
|
||||
values.append(json.dumps(screenshots))
|
||||
if documents is not None:
|
||||
fields.append("documents = %s")
|
||||
values.append(json.dumps(documents))
|
||||
if ein:
|
||||
fields.append("ein = %s")
|
||||
values.append(ein)
|
||||
if status == "filed":
|
||||
fields.append("filed_at = NOW()")
|
||||
|
||||
values.append(order_id)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE formation_orders SET {', '.join(fields)} WHERE order_id = %s",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _update_automation_status(conn, order_id: str, auto_status: str, *, error: str | None = None):
|
||||
"""Update the automation_status and related fields."""
|
||||
fields = ["automation_status = %s", "last_activity_at = NOW()"]
|
||||
values: list = [auto_status]
|
||||
if error:
|
||||
fields.append("automation_error = %s")
|
||||
values.append(error)
|
||||
values.append(order_id)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE formation_orders SET {', '.join(fields)} WHERE id = %s",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _increment_attempts(conn, order_id: str):
|
||||
"""Increment the automation_attempts counter."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE formation_orders SET automation_attempts = automation_attempts + 1 WHERE id = %s",
|
||||
[order_id],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _write_audit(
|
||||
conn,
|
||||
order_id,
|
||||
order_number: str,
|
||||
action: str,
|
||||
from_status: str = "",
|
||||
to_status: str = "",
|
||||
actor_type: str = "worker",
|
||||
*,
|
||||
note: str = "",
|
||||
metadata: dict | None = None,
|
||||
):
|
||||
"""Write an entry to the order_audit_log table."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO order_audit_log
|
||||
(order_type, order_id, order_number, action, from_status, to_status,
|
||||
actor_type, actor_name, note, metadata)
|
||||
VALUES ('formation', %s, %s, %s, %s, %s, %s, 'formation_worker', %s, %s)""",
|
||||
[order_id, order_number, action, from_status or None, to_status or None,
|
||||
actor_type, note or None, json.dumps(metadata) if metadata else None],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _row_to_order(row: dict) -> FormationOrder:
|
||||
"""Convert a database row dict to a FormationOrder dataclass."""
|
||||
members_raw = row.get("members")
|
||||
if isinstance(members_raw, str):
|
||||
members_raw = json.loads(members_raw)
|
||||
elif members_raw is None:
|
||||
members_raw = []
|
||||
|
||||
members = []
|
||||
for m in members_raw:
|
||||
members.append(
|
||||
Member(
|
||||
name=m.get("name", ""),
|
||||
address=m.get("address", ""),
|
||||
city=m.get("city", ""),
|
||||
state=m.get("state", ""),
|
||||
zip_code=m.get("zip_code", ""),
|
||||
title=m.get("title", "Member"),
|
||||
ownership_pct=float(m.get("ownership_pct", 0)),
|
||||
is_organizer=bool(m.get("is_organizer", False)),
|
||||
)
|
||||
)
|
||||
|
||||
entity_type_raw = row.get("entity_type", "llc")
|
||||
try:
|
||||
entity_type = EntityType(entity_type_raw)
|
||||
except ValueError:
|
||||
entity_type = EntityType.LLC
|
||||
|
||||
return FormationOrder(
|
||||
order_id=str(row["order_id"]),
|
||||
state_code=row.get("state_code", ""),
|
||||
entity_type=entity_type,
|
||||
entity_name=row.get("entity_name", ""),
|
||||
entity_name_alt=row.get("entity_name_alt", ""),
|
||||
management_type=row.get("management_type", "member_managed"),
|
||||
purpose=row.get("purpose", "Any lawful business activity"),
|
||||
members=members,
|
||||
registered_agent_name=row.get("registered_agent_name", "Northwest Registered Agent"),
|
||||
registered_agent_address=row.get("registered_agent_address", ""),
|
||||
principal_address=row.get("principal_address", ""),
|
||||
principal_city=row.get("principal_city", ""),
|
||||
principal_state=row.get("principal_state", ""),
|
||||
principal_zip=row.get("principal_zip", ""),
|
||||
mailing_address=row.get("mailing_address", ""),
|
||||
mailing_city=row.get("mailing_city", ""),
|
||||
mailing_state=row.get("mailing_state", ""),
|
||||
mailing_zip=row.get("mailing_zip", ""),
|
||||
shares_authorized=int(row.get("shares_authorized", 1500)),
|
||||
par_value=float(row.get("par_value", 0.0)),
|
||||
fiscal_year_end=row.get("fiscal_year_end", "12/31"),
|
||||
expedited=bool(row.get("expedited", False)),
|
||||
effective_date=row.get("effective_date", "") or "",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_order(order: FormationOrder, conn) -> FilingResult:
|
||||
"""
|
||||
Process a single formation order:
|
||||
1. Verify name availability
|
||||
2. File the entity
|
||||
3. Return the result
|
||||
"""
|
||||
state_code = order.state_code.upper()
|
||||
LOG.info(
|
||||
"Processing order %s: %s in %s (%s)",
|
||||
order.order_id,
|
||||
order.entity_name,
|
||||
state_code,
|
||||
order.entity_type.value,
|
||||
)
|
||||
|
||||
adapter = get_adapter(state_code)
|
||||
|
||||
try:
|
||||
await adapter.start_browser(headless=True)
|
||||
|
||||
# Step 1: Verify name availability
|
||||
LOG.info("[%s] Searching name: %s", order.order_id, order.entity_name)
|
||||
name_result: NameSearchResult = await adapter.search_name(order.entity_name)
|
||||
|
||||
if not name_result.available:
|
||||
# Try alternate name if provided
|
||||
if order.entity_name_alt:
|
||||
LOG.info(
|
||||
"[%s] Primary name unavailable, trying alternate: %s",
|
||||
order.order_id,
|
||||
order.entity_name_alt,
|
||||
)
|
||||
name_result = await adapter.search_name(order.entity_name_alt)
|
||||
if name_result.available:
|
||||
order.entity_name = order.entity_name_alt
|
||||
else:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.NAME_UNAVAILABLE,
|
||||
state_code=state_code,
|
||||
entity_name=order.entity_name,
|
||||
error_message=(
|
||||
f"Both names unavailable: '{order.entity_name}' "
|
||||
f"and '{order.entity_name_alt}'"
|
||||
),
|
||||
screenshot_path=await adapter.screenshot("name_unavailable"),
|
||||
)
|
||||
else:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.NAME_UNAVAILABLE,
|
||||
state_code=state_code,
|
||||
entity_name=order.entity_name,
|
||||
error_message=f"Name unavailable: '{order.entity_name}'",
|
||||
screenshot_path=await adapter.screenshot("name_unavailable"),
|
||||
)
|
||||
|
||||
LOG.info("[%s] Name available: %s", order.order_id, order.entity_name)
|
||||
|
||||
# Step 2: File the entity
|
||||
LOG.info("[%s] Filing entity...", order.order_id)
|
||||
result: FilingResult = await adapter.file_entity(order)
|
||||
LOG.info(
|
||||
"[%s] Filing result: %s (filing_number=%s, confirmation=%s)",
|
||||
order.order_id,
|
||||
result.status.value,
|
||||
result.filing_number,
|
||||
result.confirmation_number,
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
LOG.error(
|
||||
"[%s] Unhandled error processing order: %s",
|
||||
order.order_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
screenshot = ""
|
||||
try:
|
||||
screenshot = await adapter.screenshot("error")
|
||||
except Exception:
|
||||
pass
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=state_code,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(exc),
|
||||
screenshot_path=screenshot,
|
||||
)
|
||||
finally:
|
||||
await adapter.close_browser()
|
||||
|
||||
|
||||
async def poll_and_process():
|
||||
"""Single poll iteration: fetch pending orders and process them."""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
orders = _fetch_pending_orders(conn)
|
||||
if not orders:
|
||||
return
|
||||
|
||||
LOG.info("Found %d pending order(s)", len(orders))
|
||||
|
||||
for i, row in enumerate(orders):
|
||||
order = _row_to_order(row)
|
||||
|
||||
# Mark as processing + set automation_status to running
|
||||
_update_order_status(conn, order.order_id, "processing")
|
||||
_update_automation_status(conn, order.order_id, "running")
|
||||
_write_audit(conn, order.order_id, row.get("order_number", ""),
|
||||
"status_change", "received", "processing",
|
||||
"worker", note="Automation started")
|
||||
|
||||
# Process the order
|
||||
result = await process_order(order, conn)
|
||||
|
||||
# Map result to DB status
|
||||
if result.status == FilingStatus.FILED:
|
||||
db_status = "filed"
|
||||
auto_status = "succeeded"
|
||||
elif result.status == FilingStatus.SUBMITTED:
|
||||
db_status = "submitted"
|
||||
auto_status = "running"
|
||||
elif result.status == FilingStatus.NAME_UNAVAILABLE:
|
||||
db_status = "received" # Keep in queue for admin review
|
||||
auto_status = "failed"
|
||||
else:
|
||||
db_status = "received" # Keep in queue for manual intervention
|
||||
auto_status = "failed"
|
||||
|
||||
# Update the order
|
||||
screenshots = [result.screenshot_path] if result.screenshot_path else []
|
||||
_update_order_status(
|
||||
conn,
|
||||
order.order_id,
|
||||
db_status,
|
||||
filing_number=result.filing_number,
|
||||
confirmation_number=result.confirmation_number,
|
||||
error_message=result.error_message,
|
||||
screenshots=screenshots,
|
||||
documents=result.documents,
|
||||
)
|
||||
_update_automation_status(
|
||||
conn, order.order_id, auto_status,
|
||||
error=result.error_message if auto_status == "failed" else None,
|
||||
)
|
||||
_write_audit(
|
||||
conn, order.order_id, row.get("order_number", ""),
|
||||
"automation_update" if auto_status != "succeeded" else "status_change",
|
||||
"processing", db_status, "worker",
|
||||
note=result.error_message if auto_status == "failed"
|
||||
else f"Filed: {result.filing_number}" if result.filing_number
|
||||
else f"Status: {db_status}",
|
||||
metadata={"filing_number": result.filing_number,
|
||||
"confirmation": result.confirmation_number,
|
||||
"screenshot": result.screenshot_path} if result.filing_number else None,
|
||||
)
|
||||
|
||||
if auto_status == "failed":
|
||||
# Increment attempt counter and alert
|
||||
_increment_attempts(conn, order.order_id)
|
||||
_alert_error(
|
||||
order.order_id,
|
||||
order.state_code,
|
||||
result.error_message,
|
||||
detail=f"Entity: {order.entity_name}\nState: {order.state_code}\n"
|
||||
f"Filing number: {result.filing_number}\n"
|
||||
f"Screenshot: {result.screenshot_path}\n"
|
||||
f"Status set to: {auto_status}\n"
|
||||
f"Order returned to queue for manual intervention.",
|
||||
)
|
||||
|
||||
# Human-paced delay between orders (skip after last order)
|
||||
if i < len(orders) - 1:
|
||||
delay_minutes = random.uniform(DELAY_MIN_MINUTES, DELAY_MAX_MINUTES)
|
||||
delay_seconds = delay_minutes * 60
|
||||
LOG.info(
|
||||
"Waiting %.1f minutes before next order (human-paced delay)...",
|
||||
delay_minutes,
|
||||
)
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop with single-instance locking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_shutdown = False
|
||||
|
||||
|
||||
def _handle_signal(signum, frame):
|
||||
global _shutdown
|
||||
LOG.info("Received signal %d, shutting down gracefully...", signum)
|
||||
_shutdown = True
|
||||
|
||||
|
||||
async def run_worker():
|
||||
"""Main worker loop: poll for orders, process them, sleep, repeat."""
|
||||
LOG.info("=" * 60)
|
||||
LOG.info("Formation worker starting")
|
||||
LOG.info(" Poll interval: %ds", POLL_INTERVAL_SECONDS)
|
||||
LOG.info(" Delay range: %d–%d minutes", DELAY_MIN_MINUTES, DELAY_MAX_MINUTES)
|
||||
LOG.info(" Log file: %s", LOG_FILE)
|
||||
LOG.info("=" * 60)
|
||||
|
||||
while not _shutdown:
|
||||
try:
|
||||
await poll_and_process()
|
||||
except Exception as exc:
|
||||
LOG.error("Poll cycle failed: %s", exc, exc_info=True)
|
||||
_alert_error("N/A", "N/A", f"Poll cycle failed: {exc}", traceback.format_exc())
|
||||
|
||||
# Sleep in short increments so we can respond to shutdown signals
|
||||
for _ in range(POLL_INTERVAL_SECONDS):
|
||||
if _shutdown:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
LOG.info("Formation worker stopped.")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point with single-instance locking."""
|
||||
if not DATABASE_URL:
|
||||
print(
|
||||
"Error: DATABASE_URL environment variable is not set.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Acquire single-instance lock
|
||||
lock_fd = open(LOCK_FILE, "w")
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print(
|
||||
"Error: Another formation worker is already running (lock held).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Write PID to lock file
|
||||
lock_fd.write(str(os.getpid()))
|
||||
lock_fd.flush()
|
||||
|
||||
# Register signal handlers
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
try:
|
||||
asyncio.run(run_worker())
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
try:
|
||||
os.unlink(LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
304
scripts/formation/holidays.py
Normal file
304
scripts/formation/holidays.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""
|
||||
Holiday calendar for web automation scheduling.
|
||||
|
||||
Covers:
|
||||
- US federal holidays (observed) — relevant to IRS, SSA, and US state portals
|
||||
- Canadian federal statutory holidays — relevant to CRTC, BC Registry
|
||||
- BC provincial holidays — relevant to BC Corporate Registry, BC Online
|
||||
- State-specific observed holidays (closures vary by SOS office)
|
||||
|
||||
Usage:
|
||||
from scripts.formation.holidays import is_holiday, next_business_day
|
||||
|
||||
if is_holiday(date.today(), jurisdiction="US"):
|
||||
...
|
||||
if is_holiday(date.today(), jurisdiction="BC"):
|
||||
...
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal, Optional
|
||||
|
||||
|
||||
Jurisdiction = Literal["US", "CA", "BC", "IRS"]
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _nth_weekday(year: int, month: int, weekday: int, n: int) -> date:
|
||||
"""Return the nth occurrence of weekday (Mon=0 … Sun=6) in given month/year.
|
||||
n=1 → first, n=2 → second, n=-1 → last.
|
||||
"""
|
||||
if n > 0:
|
||||
first = date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
return first + timedelta(days=offset + 7 * (n - 1))
|
||||
else: # last
|
||||
# find last day, walk back
|
||||
if month == 12:
|
||||
last = date(year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
last = date(year, month + 1, 1) - timedelta(days=1)
|
||||
offset = (last.weekday() - weekday) % 7
|
||||
return last - timedelta(days=offset)
|
||||
|
||||
|
||||
def _observed(d: date) -> date:
|
||||
"""Return the observed date for a holiday falling on a weekend.
|
||||
Saturday → Friday, Sunday → Monday.
|
||||
"""
|
||||
if d.weekday() == 5: # Saturday
|
||||
return d - timedelta(days=1)
|
||||
if d.weekday() == 6: # Sunday
|
||||
return d + timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
# ── US Federal Holidays ────────────────────────────────────────────────────────
|
||||
|
||||
def _us_federal_holidays(year: int) -> set[date]:
|
||||
"""Return the set of US federal holiday observed dates for a given year."""
|
||||
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
|
||||
holidays = set()
|
||||
|
||||
# New Year's Day — Jan 1
|
||||
holidays.add(_observed(date(year, 1, 1)))
|
||||
|
||||
# Martin Luther King Jr. Day — 3rd Monday in January
|
||||
holidays.add(_nth_weekday(year, 1, MON, 3))
|
||||
|
||||
# Presidents' Day (Washington's Birthday) — 3rd Monday in February
|
||||
holidays.add(_nth_weekday(year, 2, MON, 3))
|
||||
|
||||
# Memorial Day — last Monday in May
|
||||
holidays.add(_nth_weekday(year, 5, MON, -1))
|
||||
|
||||
# Juneteenth — June 19
|
||||
holidays.add(_observed(date(year, 6, 19)))
|
||||
|
||||
# Independence Day — July 4
|
||||
holidays.add(_observed(date(year, 7, 4)))
|
||||
|
||||
# Labor Day — 1st Monday in September
|
||||
holidays.add(_nth_weekday(year, 9, MON, 1))
|
||||
|
||||
# Columbus Day — 2nd Monday in October
|
||||
holidays.add(_nth_weekday(year, 10, MON, 2))
|
||||
|
||||
# Veterans Day — November 11
|
||||
holidays.add(_observed(date(year, 11, 11)))
|
||||
|
||||
# Thanksgiving — 4th Thursday in November
|
||||
holidays.add(_nth_weekday(year, 11, THU, 4))
|
||||
|
||||
# Christmas — December 25
|
||||
holidays.add(_observed(date(year, 12, 25)))
|
||||
|
||||
# New Year's Day (observed for next year, sometimes Dec 31)
|
||||
ny_next = _observed(date(year + 1, 1, 1))
|
||||
if ny_next.year == year:
|
||||
holidays.add(ny_next)
|
||||
|
||||
return holidays
|
||||
|
||||
|
||||
# ── Canadian Federal Statutory Holidays ───────────────────────────────────────
|
||||
|
||||
def _canada_federal_holidays(year: int) -> set[date]:
|
||||
"""Return Canadian federal statutory holiday dates for a given year."""
|
||||
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
|
||||
holidays = set()
|
||||
|
||||
# New Year's Day
|
||||
holidays.add(_observed(date(year, 1, 1)))
|
||||
|
||||
# Good Friday — Friday before Easter
|
||||
easter = _easter(year)
|
||||
holidays.add(easter - timedelta(days=2)) # Good Friday
|
||||
|
||||
# Easter Monday
|
||||
holidays.add(easter + timedelta(days=1))
|
||||
|
||||
# Victoria Day — Monday before May 25
|
||||
may25 = date(year, 5, 25)
|
||||
days_since_mon = may25.weekday() # Mon=0
|
||||
if days_since_mon == 0:
|
||||
holidays.add(may25 - timedelta(days=7))
|
||||
else:
|
||||
holidays.add(may25 - timedelta(days=days_since_mon))
|
||||
|
||||
# Canada Day — July 1
|
||||
holidays.add(_observed(date(year, 7, 1)))
|
||||
|
||||
# Labour Day — 1st Monday in September
|
||||
holidays.add(_nth_weekday(year, 9, MON, 1))
|
||||
|
||||
# National Day for Truth and Reconciliation — Sept 30 (federal)
|
||||
holidays.add(_observed(date(year, 9, 30)))
|
||||
|
||||
# Thanksgiving — 2nd Monday in October
|
||||
holidays.add(_nth_weekday(year, 10, MON, 2))
|
||||
|
||||
# Remembrance Day — November 11
|
||||
holidays.add(_observed(date(year, 11, 11)))
|
||||
|
||||
# Christmas Day — December 25
|
||||
holidays.add(_observed(date(year, 12, 25)))
|
||||
|
||||
# Boxing Day — December 26
|
||||
holidays.add(_observed(date(year, 12, 26)))
|
||||
|
||||
return holidays
|
||||
|
||||
|
||||
# ── BC Provincial Holidays ────────────────────────────────────────────────────
|
||||
|
||||
def _bc_holidays(year: int) -> set[date]:
|
||||
"""Return BC provincial statutory holidays (superset of Canadian federal)."""
|
||||
MON = 0
|
||||
holidays = _canada_federal_holidays(year)
|
||||
|
||||
# BC Day — 1st Monday in August
|
||||
holidays.add(_nth_weekday(year, 8, MON, 1))
|
||||
|
||||
# Family Day — 3rd Monday in February (BC-specific — started 2013)
|
||||
if year >= 2013:
|
||||
holidays.add(_nth_weekday(year, 2, MON, 3))
|
||||
|
||||
return holidays
|
||||
|
||||
|
||||
# ── Easter (Gregorian) ────────────────────────────────────────────────────────
|
||||
|
||||
def _easter(year: int) -> date:
|
||||
"""Return date of Easter Sunday using the Anonymous Gregorian algorithm."""
|
||||
a = year % 19
|
||||
b = year // 100
|
||||
c = year % 100
|
||||
d = b // 4
|
||||
e = b % 4
|
||||
f = (b + 8) // 25
|
||||
g = (b - f + 1) // 3
|
||||
h = (19 * a + b - d - g + 15) % 30
|
||||
i = c // 4
|
||||
k = c % 4
|
||||
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||
m = (a + 11 * h + 22 * l) // 451
|
||||
month = (h + l - 7 * m + 114) // 31
|
||||
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_cache: dict[tuple[int, str], set[date]] = {}
|
||||
|
||||
|
||||
def _get_holidays(year: int, jurisdiction: Jurisdiction) -> set[date]:
|
||||
key = (year, jurisdiction)
|
||||
if key not in _cache:
|
||||
if jurisdiction == "US" or jurisdiction == "IRS":
|
||||
_cache[key] = _us_federal_holidays(year)
|
||||
elif jurisdiction == "CA":
|
||||
_cache[key] = _canada_federal_holidays(year)
|
||||
elif jurisdiction == "BC":
|
||||
_cache[key] = _bc_holidays(year)
|
||||
else:
|
||||
_cache[key] = set()
|
||||
return _cache[key]
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_holiday(d: date, jurisdiction: Jurisdiction = "US") -> bool:
|
||||
"""Return True if `d` is a holiday in the given jurisdiction."""
|
||||
return d in _get_holidays(d.year, jurisdiction)
|
||||
|
||||
|
||||
def is_weekend(d: date) -> bool:
|
||||
"""Return True if `d` is Saturday or Sunday."""
|
||||
return d.weekday() >= 5
|
||||
|
||||
|
||||
def is_business_day(d: date, jurisdiction: Jurisdiction = "US") -> bool:
|
||||
"""Return True if `d` is a weekday and not a holiday."""
|
||||
return not is_weekend(d) and not is_holiday(d, jurisdiction)
|
||||
|
||||
|
||||
def next_business_day(
|
||||
after: Optional[date] = None,
|
||||
jurisdiction: Jurisdiction = "US",
|
||||
) -> date:
|
||||
"""Return the next business day after `after` (default: today)."""
|
||||
d = (after or date.today()) + timedelta(days=1)
|
||||
while not is_business_day(d, jurisdiction):
|
||||
d += timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
def holiday_name(d: date, jurisdiction: Jurisdiction = "US") -> Optional[str]:
|
||||
"""Return a human-readable name for the holiday on `d`, or None."""
|
||||
# Build a labelled lookup for the year
|
||||
labels = _labelled_holidays(d.year, jurisdiction)
|
||||
return labels.get(d)
|
||||
|
||||
|
||||
def _labelled_holidays(year: int, jurisdiction: Jurisdiction) -> dict[date, str]:
|
||||
"""Return holiday dates mapped to their names."""
|
||||
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
|
||||
result: dict[date, str] = {}
|
||||
|
||||
if jurisdiction in ("US", "IRS"):
|
||||
result[_observed(date(year, 1, 1))] = "New Year's Day"
|
||||
result[_nth_weekday(year, 1, MON, 3)] = "Martin Luther King Jr. Day"
|
||||
result[_nth_weekday(year, 2, MON, 3)] = "Presidents' Day"
|
||||
result[_nth_weekday(year, 5, MON, -1)] = "Memorial Day"
|
||||
result[_observed(date(year, 6, 19))] = "Juneteenth"
|
||||
result[_observed(date(year, 7, 4))] = "Independence Day"
|
||||
result[_nth_weekday(year, 9, MON, 1)] = "Labor Day"
|
||||
result[_nth_weekday(year, 10, MON, 2)] = "Columbus Day"
|
||||
result[_observed(date(year, 11, 11))] = "Veterans Day"
|
||||
result[_nth_weekday(year, 11, THU, 4)] = "Thanksgiving"
|
||||
result[_observed(date(year, 12, 25))] = "Christmas Day"
|
||||
ny_next = _observed(date(year + 1, 1, 1))
|
||||
if ny_next.year == year:
|
||||
result[ny_next] = "New Year's Day (observed)"
|
||||
|
||||
elif jurisdiction in ("CA", "BC"):
|
||||
easter = _easter(year)
|
||||
result[_observed(date(year, 1, 1))] = "New Year's Day"
|
||||
result[easter - timedelta(days=2)] = "Good Friday"
|
||||
result[easter + timedelta(days=1)] = "Easter Monday"
|
||||
may25 = date(year, 5, 25)
|
||||
daysback = may25.weekday() if may25.weekday() > 0 else 7
|
||||
result[may25 - timedelta(days=daysback)] = "Victoria Day"
|
||||
result[_observed(date(year, 7, 1))] = "Canada Day"
|
||||
result[_nth_weekday(year, 9, MON, 1)] = "Labour Day"
|
||||
result[_observed(date(year, 9, 30))] = "National Day for Truth and Reconciliation"
|
||||
result[_nth_weekday(year, 10, MON, 2)] = "Thanksgiving"
|
||||
result[_observed(date(year, 11, 11))] = "Remembrance Day"
|
||||
result[_observed(date(year, 12, 25))] = "Christmas Day"
|
||||
result[_observed(date(year, 12, 26))] = "Boxing Day"
|
||||
if jurisdiction == "BC":
|
||||
result[_nth_weekday(year, 8, MON, 1)] = "BC Day"
|
||||
if year >= 2013:
|
||||
result[_nth_weekday(year, 2, MON, 3)] = "Family Day (BC)"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def upcoming_holidays(
|
||||
days: int = 30,
|
||||
jurisdiction: Jurisdiction = "US",
|
||||
from_date: Optional[date] = None,
|
||||
) -> list[tuple[date, str]]:
|
||||
"""Return (date, name) pairs for holidays in the next `days` days."""
|
||||
start = from_date or date.today()
|
||||
end = start + timedelta(days=days)
|
||||
labelled = _labelled_holidays(start.year, jurisdiction)
|
||||
if end.year != start.year:
|
||||
labelled.update(_labelled_holidays(end.year, jurisdiction))
|
||||
return sorted(
|
||||
[(d, name) for d, name in labelled.items() if start <= d <= end],
|
||||
key=lambda x: x[0],
|
||||
)
|
||||
313
scripts/formation/jurisdictions/__init__.py
Normal file
313
scripts/formation/jurisdictions/__init__.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Unified jurisdiction abstraction for US states + Canadian provinces.
|
||||
|
||||
This module sits alongside the legacy `scripts.formation.states` registry
|
||||
and reads from the `jurisdictions` Postgres table (migration 066). It's
|
||||
the canonical source of jurisdiction metadata going forward.
|
||||
|
||||
Why we have both:
|
||||
- `scripts.formation.states` still owns per-jurisdiction Playwright
|
||||
adapters (`adapter.py` + `config.py` per state) because those files
|
||||
contain the hand-written CSS selectors + portal-specific flows.
|
||||
- `scripts.formation.jurisdictions` owns the *data* (currency, country,
|
||||
entity types, portal URL, NWRA wholesale pricing) that's
|
||||
jurisdiction-agnostic and read from the DB.
|
||||
|
||||
The two are joined by state code. `JurisdictionConfig.adapter()` returns
|
||||
the legacy adapter for a code so callers don't have to care.
|
||||
|
||||
Usage:
|
||||
|
||||
from scripts.formation.jurisdictions import get_jurisdiction
|
||||
|
||||
j = get_jurisdiction("WY")
|
||||
j.country # 'US'
|
||||
j.currency # 'USD'
|
||||
j.entity_types # [{'code':'llc','label':'LLC'}, ...]
|
||||
j.foreign_qualification_fee_cents("llc") # from state_filing_fees
|
||||
adapter = j.adapter() # legacy StatePortal instance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
LOG = logging.getLogger("formation.jurisdictions")
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────── #
|
||||
# Data classes
|
||||
# ────────────────────────────────────────────────────────────────────── #
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityTypeSpec:
|
||||
"""One entity type a jurisdiction recognizes."""
|
||||
code: str # 'llc' | 'corporation' | 'ltd' | 'inc' | ...
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class JurisdictionConfig:
|
||||
"""Unified config for a single US state / DC / Canadian province.
|
||||
|
||||
Fields mirror the `jurisdictions` table. `state_filing_fees` data is
|
||||
lazy-loaded via the helper methods so we don't pay a second DB hit
|
||||
on every access.
|
||||
"""
|
||||
code: str
|
||||
name: str
|
||||
country: str # 'US' | 'CA'
|
||||
kind: str # 'state' | 'district' | 'province' | 'territory'
|
||||
currency: str # 'USD' | 'CAD'
|
||||
timezone: Optional[str] = None
|
||||
|
||||
portal_name: Optional[str] = None
|
||||
portal_url: Optional[str] = None
|
||||
portal_login_required: bool = False
|
||||
|
||||
entity_types: list[EntityTypeSpec] = field(default_factory=list)
|
||||
|
||||
supports_foreign_qualification: bool = True
|
||||
foreign_qual_portal_url: Optional[str] = None
|
||||
foreign_qual_requires_coa: bool = True
|
||||
|
||||
nwra_foreign_qual_wholesale_cents: Optional[int] = None
|
||||
|
||||
notes: Optional[str] = None
|
||||
|
||||
# ────────────────────────────────────────────────────────────────── #
|
||||
# Fee lookups — read on demand from state_filing_fees
|
||||
# ────────────────────────────────────────────────────────────────── #
|
||||
|
||||
def foreign_qualification_fee_cents(self, entity_type: str) -> Optional[int]:
|
||||
"""Return target-state's foreign qualification fee for this entity type."""
|
||||
col = _FOREIGN_QUAL_FEE_COL.get(_normalize_entity_type(entity_type))
|
||||
if not col:
|
||||
return None
|
||||
row = _query_one(
|
||||
f"SELECT {col} AS fee FROM state_filing_fees WHERE state_code = %s",
|
||||
(self.code,),
|
||||
)
|
||||
return int(row["fee"]) if row and row["fee"] is not None else None
|
||||
|
||||
def formation_fee_cents(self, entity_type: str) -> Optional[int]:
|
||||
"""Return home-state's formation fee for this entity type."""
|
||||
col = _FORMATION_FEE_COL.get(_normalize_entity_type(entity_type))
|
||||
if not col:
|
||||
return None
|
||||
row = _query_one(
|
||||
f"SELECT {col} AS fee FROM state_filing_fees WHERE state_code = %s",
|
||||
(self.code,),
|
||||
)
|
||||
return int(row["fee"]) if row and row["fee"] is not None else None
|
||||
|
||||
def expedited_fee_cents(self) -> Optional[int]:
|
||||
"""Return expedited fee in cents, normalized from the seeded value.
|
||||
|
||||
`state_filing_fees.expedited_fee` was seeded inconsistently — for
|
||||
some states it was stored as dollars × 10000 (the DB convention
|
||||
used by `expedited_fee_cents` in formation_orders is cents), so
|
||||
we divide by 100 if the stored value is suspiciously large. See
|
||||
the same normalization in `scripts.workers.crypto_offramp.sizer`.
|
||||
"""
|
||||
row = _query_one(
|
||||
"SELECT expedited_fee FROM state_filing_fees WHERE state_code = %s",
|
||||
(self.code,),
|
||||
)
|
||||
if not row or row["expedited_fee"] is None:
|
||||
return None
|
||||
raw = int(row["expedited_fee"])
|
||||
return raw // 100 if raw > 50000 else raw
|
||||
|
||||
def requires_publication(self) -> bool:
|
||||
"""Some states (NY, AZ, NE) require newspaper publication after filing."""
|
||||
row = _query_one(
|
||||
"SELECT publication_required FROM state_filing_fees WHERE state_code = %s",
|
||||
(self.code,),
|
||||
)
|
||||
return bool(row and row.get("publication_required"))
|
||||
|
||||
# ────────────────────────────────────────────────────────────────── #
|
||||
# Adapter bridge — return the legacy StatePortal for this code.
|
||||
# ────────────────────────────────────────────────────────────────── #
|
||||
|
||||
def adapter(self):
|
||||
"""Dynamically import the state's StatePortal adapter.
|
||||
|
||||
Bridges to `scripts.formation.states.{code}.adapter`. Raises
|
||||
`ImportError` if the adapter hasn't been written yet (not every
|
||||
jurisdiction has a filer).
|
||||
"""
|
||||
from scripts.formation.states import get_adapter
|
||||
return get_adapter(self.code)
|
||||
|
||||
def has_adapter(self) -> bool:
|
||||
"""Whether a Playwright adapter is implemented for this code."""
|
||||
try:
|
||||
from scripts.formation.states import get_adapter
|
||||
get_adapter(self.code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────── #
|
||||
# Internal helpers
|
||||
# ────────────────────────────────────────────────────────────────────── #
|
||||
|
||||
_FOREIGN_QUAL_FEE_COL = {
|
||||
"llc": "foreign_llc_fee",
|
||||
"pllc": "foreign_llc_fee",
|
||||
"corporation": "foreign_corp_fee",
|
||||
"c_corp": "foreign_corp_fee",
|
||||
"s_corp": "foreign_corp_fee",
|
||||
"pc": "foreign_corp_fee",
|
||||
"nonprofit": "foreign_corp_fee",
|
||||
}
|
||||
|
||||
_FORMATION_FEE_COL = {
|
||||
"llc": "llc_formation_fee",
|
||||
"pllc": "llc_formation_fee",
|
||||
"corporation": "corp_formation_fee",
|
||||
"c_corp": "corp_formation_fee",
|
||||
"s_corp": "corp_formation_fee",
|
||||
"pc": "corp_formation_fee",
|
||||
"nonprofit": "corp_formation_fee",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_entity_type(et: str) -> str:
|
||||
"""Collapse variants to the canonical key used in the fee-column maps."""
|
||||
return (et or "").strip().lower().replace("-", "_")
|
||||
|
||||
|
||||
def _connect():
|
||||
return psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
|
||||
|
||||
def _query_one(sql: str, params: tuple) -> Optional[dict]:
|
||||
conn = _connect()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row_to_config(row: dict) -> JurisdictionConfig:
|
||||
entity_types_raw = row.get("entity_types_json") or []
|
||||
entity_types = [
|
||||
EntityTypeSpec(code=e["code"], label=e["label"])
|
||||
for e in entity_types_raw
|
||||
if isinstance(e, dict) and "code" in e
|
||||
]
|
||||
return JurisdictionConfig(
|
||||
code=row["code"],
|
||||
name=row["name"],
|
||||
country=row["country"],
|
||||
kind=row["kind"],
|
||||
currency=row["currency"],
|
||||
timezone=row.get("timezone"),
|
||||
portal_name=row.get("portal_name"),
|
||||
portal_url=row.get("portal_url"),
|
||||
portal_login_required=bool(row.get("portal_login_required", False)),
|
||||
entity_types=entity_types,
|
||||
supports_foreign_qualification=bool(
|
||||
row.get("supports_foreign_qualification", True),
|
||||
),
|
||||
foreign_qual_portal_url=row.get("foreign_qual_portal_url"),
|
||||
foreign_qual_requires_coa=bool(row.get("foreign_qual_requires_coa", True)),
|
||||
nwra_foreign_qual_wholesale_cents=row.get("nwra_foreign_qual_wholesale_cents"),
|
||||
notes=row.get("notes"),
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────── #
|
||||
# Public API
|
||||
# ────────────────────────────────────────────────────────────────────── #
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_jurisdiction(code: str) -> JurisdictionConfig:
|
||||
"""Load the jurisdiction for `code` from the DB.
|
||||
|
||||
Cached — safe because the table is seeded once per deploy. Flush
|
||||
with `get_jurisdiction.cache_clear()` if you update a row live.
|
||||
"""
|
||||
code_u = (code or "").strip().upper()
|
||||
if not code_u:
|
||||
raise ValueError("jurisdiction code required")
|
||||
row = _query_one(
|
||||
"""
|
||||
SELECT code, name, country, kind, currency, timezone,
|
||||
portal_name, portal_url, portal_login_required,
|
||||
entity_types_json,
|
||||
supports_foreign_qualification,
|
||||
foreign_qual_portal_url, foreign_qual_requires_coa,
|
||||
nwra_foreign_qual_wholesale_cents,
|
||||
notes
|
||||
FROM jurisdictions
|
||||
WHERE code = %s
|
||||
""",
|
||||
(code_u,),
|
||||
)
|
||||
if not row:
|
||||
raise ValueError(f"Unknown jurisdiction code: {code_u}")
|
||||
return _row_to_config(row)
|
||||
|
||||
|
||||
def list_jurisdictions(
|
||||
country: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
supports_foreign_qualification: Optional[bool] = None,
|
||||
) -> list[JurisdictionConfig]:
|
||||
"""Return every jurisdiction, optionally filtered."""
|
||||
sql = """
|
||||
SELECT code, name, country, kind, currency, timezone,
|
||||
portal_name, portal_url, portal_login_required,
|
||||
entity_types_json,
|
||||
supports_foreign_qualification,
|
||||
foreign_qual_portal_url, foreign_qual_requires_coa,
|
||||
nwra_foreign_qual_wholesale_cents,
|
||||
notes
|
||||
FROM jurisdictions
|
||||
"""
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
if country:
|
||||
conditions.append("country = %s")
|
||||
params.append(country.upper())
|
||||
if kind:
|
||||
conditions.append("kind = %s")
|
||||
params.append(kind.lower())
|
||||
if supports_foreign_qualification is not None:
|
||||
conditions.append("supports_foreign_qualification = %s")
|
||||
params.append(supports_foreign_qualification)
|
||||
if conditions:
|
||||
sql += "\n WHERE " + " AND ".join(conditions)
|
||||
sql += "\n ORDER BY country, code"
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, tuple(params))
|
||||
return [_row_to_config(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EntityTypeSpec",
|
||||
"JurisdictionConfig",
|
||||
"get_jurisdiction",
|
||||
"list_jurisdictions",
|
||||
]
|
||||
178
scripts/formation/name_search.py
Normal file
178
scripts/formation/name_search.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
name_search.py — Multi-state business name availability search coordinator.
|
||||
|
||||
Loads the appropriate state adapter and performs name availability searches.
|
||||
Supports searching a single state or multiple states in parallel.
|
||||
|
||||
Usage:
|
||||
python -m formation.name_search "My Business LLC" WY
|
||||
python -m formation.name_search "My Business LLC" WY,NV,NM,TX
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
||||
from .base import NameSearchResult
|
||||
from .states import get_adapter, STATES
|
||||
|
||||
LOG = logging.getLogger("formation.name_search")
|
||||
|
||||
|
||||
async def search_name(name: str, state_code: str) -> NameSearchResult:
|
||||
"""
|
||||
Search for business name availability in a single state.
|
||||
|
||||
Loads the state adapter, launches a browser session, performs the search,
|
||||
and returns a NameSearchResult.
|
||||
|
||||
Args:
|
||||
name: The business name to search (e.g. "Acme Holdings LLC").
|
||||
state_code: Two-letter state code (e.g. "WY").
|
||||
|
||||
Returns:
|
||||
NameSearchResult with availability info.
|
||||
"""
|
||||
code = state_code.upper()
|
||||
if code not in STATES:
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
searched_name=name,
|
||||
state_code=code,
|
||||
raw_response=f"Unknown state code: {code}",
|
||||
)
|
||||
|
||||
LOG.info("Searching name '%s' in %s (%s)...", name, code, STATES[code]["name"])
|
||||
adapter = get_adapter(code)
|
||||
|
||||
try:
|
||||
await adapter.start_browser(headless=True)
|
||||
result = await adapter.search_name(name)
|
||||
# Ensure state_code and searched_name are populated
|
||||
result.state_code = result.state_code or code
|
||||
result.searched_name = result.searched_name or name
|
||||
return result
|
||||
except Exception as exc:
|
||||
LOG.error("Name search failed in %s: %s", code, exc, exc_info=True)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
searched_name=name,
|
||||
state_code=code,
|
||||
raw_response=f"Error: {exc}",
|
||||
)
|
||||
finally:
|
||||
await adapter.close_browser()
|
||||
|
||||
|
||||
async def search_multiple_states(
|
||||
name: str,
|
||||
state_codes: list[str],
|
||||
) -> list[NameSearchResult]:
|
||||
"""
|
||||
Search for business name availability across multiple states in parallel.
|
||||
|
||||
Launches concurrent searches using asyncio.gather. Each state gets its own
|
||||
browser instance so they don't interfere with each other.
|
||||
|
||||
Args:
|
||||
name: The business name to search.
|
||||
state_codes: List of two-letter state codes.
|
||||
|
||||
Returns:
|
||||
List of NameSearchResult, one per state (order matches state_codes).
|
||||
"""
|
||||
LOG.info(
|
||||
"Searching name '%s' across %d states: %s",
|
||||
name,
|
||||
len(state_codes),
|
||||
", ".join(c.upper() for c in state_codes),
|
||||
)
|
||||
start = time.monotonic()
|
||||
|
||||
tasks = [search_name(name, code) for code in state_codes]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=False)
|
||||
|
||||
elapsed = time.monotonic() - start
|
||||
available_in = [r.state_code for r in results if r.available]
|
||||
unavailable_in = [r.state_code for r in results if not r.available]
|
||||
|
||||
LOG.info(
|
||||
"Multi-state search complete in %.1fs — available: %s | unavailable: %s",
|
||||
elapsed,
|
||||
", ".join(available_in) or "(none)",
|
||||
", ".join(unavailable_in) or "(none)",
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _format_result(result: NameSearchResult) -> str:
|
||||
"""Pretty-print a single search result for CLI output."""
|
||||
status = "AVAILABLE" if result.available else "UNAVAILABLE"
|
||||
lines = [
|
||||
f" [{result.state_code}] {status} — \"{result.searched_name}\"",
|
||||
]
|
||||
if result.exact_match:
|
||||
lines.append(f" Exact match found")
|
||||
if result.similar_names:
|
||||
lines.append(f" Similar names: {', '.join(result.similar_names[:5])}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _main(name: str, raw_states: str) -> int:
|
||||
"""CLI entry point logic."""
|
||||
# Parse comma-separated or space-separated state codes
|
||||
state_codes = [s.strip().upper() for s in raw_states.replace(",", " ").split() if s.strip()]
|
||||
|
||||
if not state_codes:
|
||||
print("Error: No state codes provided.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Validate state codes
|
||||
invalid = [s for s in state_codes if s not in STATES]
|
||||
if invalid:
|
||||
print(f"Error: Unknown state code(s): {', '.join(invalid)}", file=sys.stderr)
|
||||
print(f"Valid codes: {', '.join(sorted(STATES.keys()))}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Searching: \"{name}\"")
|
||||
print(f"States: {', '.join(state_codes)}")
|
||||
print("-" * 60)
|
||||
|
||||
if len(state_codes) == 1:
|
||||
result = await search_name(name, state_codes[0])
|
||||
results = [result]
|
||||
else:
|
||||
results = await search_multiple_states(name, state_codes)
|
||||
|
||||
for r in results:
|
||||
print(_format_result(r))
|
||||
|
||||
print("-" * 60)
|
||||
available_count = sum(1 for r in results if r.available)
|
||||
print(f"Available in {available_count}/{len(results)} state(s).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python -m formation.name_search <business_name> <state_code(s)>")
|
||||
print()
|
||||
print("Examples:")
|
||||
print(' python -m formation.name_search "Acme Holdings LLC" WY')
|
||||
print(' python -m formation.name_search "Acme Holdings LLC" WY,NV,NM,TX')
|
||||
sys.exit(1)
|
||||
|
||||
business_name = sys.argv[1]
|
||||
states_arg = " ".join(sys.argv[2:]) # Allow "WY NV" or "WY,NV"
|
||||
exit_code = asyncio.run(_main(business_name, states_arg))
|
||||
sys.exit(exit_code)
|
||||
640
scripts/formation/operating_agreement.py
Normal file
640
scripts/formation/operating_agreement.py
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
"""
|
||||
operating_agreement.py — Generate LLC Operating Agreement documents.
|
||||
|
||||
Uses a template-based approach with python-docx to produce a professional
|
||||
operating agreement in both .docx and .pdf formats.
|
||||
|
||||
DISCLAIMER: This operating agreement template is for informational purposes
|
||||
only and does not constitute legal advice. Consult a licensed attorney for
|
||||
legal guidance specific to your situation.
|
||||
|
||||
Output:
|
||||
/tmp/formations/{order_id}/operating-agreement.docx
|
||||
/tmp/formations/{order_id}/operating-agreement.pdf
|
||||
|
||||
Usage:
|
||||
# Programmatic
|
||||
from formation.operating_agreement import generate_operating_agreement
|
||||
docx_path, pdf_path = generate_operating_agreement(order)
|
||||
|
||||
# CLI
|
||||
python -m formation.operating_agreement <order_id>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Inches, Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
|
||||
from .base import EntityType, FormationOrder, Member
|
||||
from .states import STATES
|
||||
|
||||
LOG = logging.getLogger("formation.oa")
|
||||
|
||||
DISCLAIMER = (
|
||||
"DISCLAIMER: This operating agreement template is for informational purposes "
|
||||
"only and does not constitute legal advice. Every business situation is unique. "
|
||||
"You should consult with a licensed attorney in your jurisdiction before relying "
|
||||
"on this document for legal purposes."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_operating_agreement(order: FormationOrder) -> tuple[str, str]:
|
||||
"""
|
||||
Generate an LLC Operating Agreement in .docx and .pdf formats.
|
||||
|
||||
Args:
|
||||
order: FormationOrder with entity, member, and management details.
|
||||
|
||||
Returns:
|
||||
Tuple of (docx_path, pdf_path).
|
||||
"""
|
||||
output_dir = Path(f"/tmp/formations/{order.order_id}")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
docx_path = output_dir / "operating-agreement.docx"
|
||||
pdf_path = output_dir / "operating-agreement.pdf"
|
||||
|
||||
state_name = STATES.get(order.state_code.upper(), {}).get("name", order.state_code)
|
||||
formation_date = order.filed_at or order.effective_date or datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
# Parse formation_date if it's ISO format
|
||||
if "T" in formation_date or (len(formation_date) == 10 and "-" in formation_date):
|
||||
try:
|
||||
dt = datetime.fromisoformat(formation_date.replace("Z", "+00:00"))
|
||||
formation_date = dt.strftime("%B %d, %Y")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
management_display = (
|
||||
"Member-Managed" if order.management_type == "member_managed" else "Manager-Managed"
|
||||
)
|
||||
|
||||
doc = Document()
|
||||
|
||||
# -- Document styles --
|
||||
style = doc.styles["Normal"]
|
||||
font = style.font
|
||||
font.name = "Times New Roman"
|
||||
font.size = Pt(11)
|
||||
style.paragraph_format.space_after = Pt(6)
|
||||
style.paragraph_format.line_spacing = 1.15
|
||||
|
||||
# -- Disclaimer --
|
||||
disclaimer_para = doc.add_paragraph()
|
||||
disclaimer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = disclaimer_para.add_run(DISCLAIMER)
|
||||
run.font.size = Pt(9)
|
||||
run.font.italic = True
|
||||
run.font.color.rgb = RGBColor(128, 128, 128)
|
||||
doc.add_paragraph() # spacer
|
||||
|
||||
# -- Title --
|
||||
title = doc.add_heading("OPERATING AGREEMENT", level=0)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
subtitle = doc.add_heading(f"OF\n{order.entity_name.upper()}", level=1)
|
||||
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
type_para = doc.add_paragraph()
|
||||
type_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = type_para.add_run(f"A {state_name} Limited Liability Company")
|
||||
run.font.size = Pt(12)
|
||||
|
||||
date_para = doc.add_paragraph()
|
||||
date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = date_para.add_run(f"Effective Date: {formation_date}")
|
||||
run.font.size = Pt(11)
|
||||
run.font.italic = True
|
||||
|
||||
doc.add_paragraph() # spacer
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE I — FORMATION
|
||||
# ======================================================================
|
||||
_add_article(doc, "I", "FORMATION")
|
||||
|
||||
_add_section(doc, "1.1", "Formation", (
|
||||
f"The Members hereby form a Limited Liability Company (the \"Company\") "
|
||||
f"under the laws of the State of {state_name}, pursuant to the "
|
||||
f"{state_name} Limited Liability Company Act (the \"Act\")."
|
||||
))
|
||||
|
||||
_add_section(doc, "1.2", "Name", (
|
||||
f"The name of the Company shall be {order.entity_name} "
|
||||
f"(the \"Company\")."
|
||||
))
|
||||
|
||||
_add_section(doc, "1.3", "Principal Office", (
|
||||
f"The principal office of the Company shall be located at "
|
||||
f"{order.principal_address or '[ADDRESS]'}, "
|
||||
f"{order.principal_city or '[CITY]'}, "
|
||||
f"{order.principal_state or '[STATE]'} "
|
||||
f"{order.principal_zip or '[ZIP]'}. "
|
||||
f"The Company may change its principal office upon written notice to all Members."
|
||||
))
|
||||
|
||||
_add_section(doc, "1.4", "Registered Agent", (
|
||||
f"The registered agent for service of process shall be "
|
||||
f"{order.registered_agent_name or '[REGISTERED AGENT]'}, "
|
||||
f"located at {order.registered_agent_address or '[REGISTERED AGENT ADDRESS]'}."
|
||||
))
|
||||
|
||||
_add_section(doc, "1.5", "Purpose", (
|
||||
f"The purpose of the Company is to engage in {order.purpose}. "
|
||||
f"The Company may engage in any other lawful activity permitted under "
|
||||
f"the Act and the laws of the State of {state_name}."
|
||||
))
|
||||
|
||||
_add_section(doc, "1.6", "Duration", (
|
||||
"The Company shall have perpetual existence unless dissolved in accordance "
|
||||
"with this Agreement or as required by law."
|
||||
))
|
||||
|
||||
_add_section(doc, "1.7", "Fiscal Year", (
|
||||
f"The fiscal year of the Company shall end on {order.fiscal_year_end or 'December 31'} "
|
||||
f"of each year."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE II — MEMBERS
|
||||
# ======================================================================
|
||||
_add_article(doc, "II", "MEMBERS")
|
||||
|
||||
_add_section(doc, "2.1", "Members", (
|
||||
"The names, addresses, and ownership interests of the Members are as follows:"
|
||||
))
|
||||
|
||||
# Members table
|
||||
if order.members:
|
||||
table = doc.add_table(rows=1, cols=4)
|
||||
table.style = "Table Grid"
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
|
||||
# Header row
|
||||
hdr = table.rows[0].cells
|
||||
for i, text in enumerate(["Member Name", "Address", "Ownership %", "Title"]):
|
||||
hdr[i].text = text
|
||||
for paragraph in hdr[i].paragraphs:
|
||||
for run in paragraph.runs:
|
||||
run.font.bold = True
|
||||
run.font.size = Pt(10)
|
||||
|
||||
# Member rows
|
||||
for member in order.members:
|
||||
row = table.add_row().cells
|
||||
row[0].text = member.name
|
||||
addr = f"{member.address}, {member.city}, {member.state} {member.zip_code}"
|
||||
row[1].text = addr.strip(", ")
|
||||
row[2].text = f"{member.ownership_pct:.1f}%"
|
||||
row[3].text = member.title
|
||||
|
||||
for cell in row:
|
||||
for paragraph in cell.paragraphs:
|
||||
for run in paragraph.runs:
|
||||
run.font.size = Pt(10)
|
||||
|
||||
# Set column widths
|
||||
for row in table.rows:
|
||||
row.cells[0].width = Inches(1.8)
|
||||
row.cells[1].width = Inches(2.5)
|
||||
row.cells[2].width = Inches(1.0)
|
||||
row.cells[3].width = Inches(1.0)
|
||||
|
||||
doc.add_paragraph() # spacer after table
|
||||
|
||||
_add_section(doc, "2.2", "Admission of New Members", (
|
||||
"New Members may be admitted to the Company only with the unanimous written "
|
||||
"consent of all existing Members. Any new Member shall execute a counterpart "
|
||||
"of this Agreement and shall be bound by all terms herein."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE III — MANAGEMENT
|
||||
# ======================================================================
|
||||
_add_article(doc, "III", "MANAGEMENT")
|
||||
|
||||
if order.management_type == "member_managed":
|
||||
_add_section(doc, "3.1", "Member-Managed", (
|
||||
"The Company shall be managed by its Members. Each Member shall have "
|
||||
"the right to participate in the management of the Company and shall "
|
||||
"have the authority to bind the Company in the ordinary course of business."
|
||||
))
|
||||
_add_section(doc, "3.2", "Voting Rights", (
|
||||
"Each Member shall have voting rights in proportion to their ownership "
|
||||
"interest. Unless otherwise specified in this Agreement, decisions "
|
||||
"shall be made by a majority vote of the membership interests."
|
||||
))
|
||||
_add_section(doc, "3.3", "Major Decisions", (
|
||||
"The following actions shall require the unanimous consent of all Members: "
|
||||
"(a) sale of all or substantially all Company assets; "
|
||||
"(b) merger or consolidation of the Company; "
|
||||
"(c) any amendment to this Operating Agreement; "
|
||||
"(d) admission of a new Member; "
|
||||
"(e) any act that would make it impossible to carry on the ordinary business "
|
||||
"of the Company."
|
||||
))
|
||||
else:
|
||||
_add_section(doc, "3.1", "Manager-Managed", (
|
||||
"The Company shall be managed by one or more Managers appointed by the "
|
||||
"Members. The Manager(s) shall have full authority to manage the business "
|
||||
"and affairs of the Company, including the authority to bind the Company "
|
||||
"in the ordinary course of business."
|
||||
))
|
||||
_add_section(doc, "3.2", "Appointment of Managers", (
|
||||
"Managers shall be appointed by a majority vote of the membership interests. "
|
||||
"A Manager may be removed at any time, with or without cause, by a majority "
|
||||
"vote of the membership interests."
|
||||
))
|
||||
_add_section(doc, "3.3", "Manager Authority", (
|
||||
"The Manager(s) shall manage the day-to-day operations of the Company. "
|
||||
"Members who are not Managers shall not participate in the management "
|
||||
"or control of the Company's business and shall have no authority to "
|
||||
"bind the Company."
|
||||
))
|
||||
_add_section(doc, "3.4", "Major Decisions", (
|
||||
"The following actions shall require the unanimous consent of all Members, "
|
||||
"regardless of management structure: "
|
||||
"(a) sale of all or substantially all Company assets; "
|
||||
"(b) merger or consolidation of the Company; "
|
||||
"(c) any amendment to this Operating Agreement; "
|
||||
"(d) admission of a new Member."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE IV — CAPITAL CONTRIBUTIONS
|
||||
# ======================================================================
|
||||
_add_article(doc, "IV", "CAPITAL CONTRIBUTIONS")
|
||||
|
||||
_add_section(doc, "4.1", "Initial Contributions", (
|
||||
"Each Member shall make an initial capital contribution to the Company "
|
||||
"in cash or property as agreed upon by the Members. The value of each "
|
||||
"Member's initial contribution shall be recorded in the Company's books."
|
||||
))
|
||||
|
||||
_add_section(doc, "4.2", "Additional Contributions", (
|
||||
"No Member shall be required to make additional capital contributions "
|
||||
"to the Company without the unanimous consent of all Members. Any "
|
||||
"additional contributions shall be made in proportion to the Members' "
|
||||
"ownership interests unless otherwise agreed."
|
||||
))
|
||||
|
||||
_add_section(doc, "4.3", "Capital Accounts", (
|
||||
"The Company shall maintain a separate capital account for each Member. "
|
||||
"Each Member's capital account shall be credited with the Member's "
|
||||
"contributions and share of profits, and debited with the Member's "
|
||||
"distributions and share of losses."
|
||||
))
|
||||
|
||||
_add_section(doc, "4.4", "No Interest on Capital", (
|
||||
"No Member shall receive interest on their capital contribution or "
|
||||
"capital account balance."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE V — DISTRIBUTIONS
|
||||
# ======================================================================
|
||||
_add_article(doc, "V", "DISTRIBUTIONS")
|
||||
|
||||
_add_section(doc, "5.1", "Distributions", (
|
||||
"Distributions of the Company's net cash flow shall be made to the Members "
|
||||
"pro rata in accordance with their respective ownership percentages, at such "
|
||||
"times and in such amounts as determined by the Members (or Manager(s), if "
|
||||
"manager-managed)."
|
||||
))
|
||||
|
||||
_add_section(doc, "5.2", "Tax Distributions", (
|
||||
"The Company shall, at a minimum, distribute to each Member an amount "
|
||||
"sufficient to cover each Member's estimated tax liability arising from "
|
||||
"the Company's income allocated to such Member, calculated at the highest "
|
||||
"applicable marginal tax rate."
|
||||
))
|
||||
|
||||
_add_section(doc, "5.3", "Limitation on Distributions", (
|
||||
"No distribution shall be made if, after giving effect to the distribution, "
|
||||
"the Company would not be able to pay its debts as they become due in the "
|
||||
"ordinary course of business."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE VI — MEETINGS AND VOTING
|
||||
# ======================================================================
|
||||
_add_article(doc, "VI", "MEETINGS AND VOTING")
|
||||
|
||||
_add_section(doc, "6.1", "Meetings", (
|
||||
"The Members shall hold an annual meeting at such time and place as "
|
||||
"determined by the Members. Special meetings may be called by any Member "
|
||||
"upon not less than ten (10) days' written notice to all other Members."
|
||||
))
|
||||
|
||||
_add_section(doc, "6.2", "Quorum", (
|
||||
"A quorum for any meeting of Members shall consist of Members holding "
|
||||
"more than fifty percent (50%) of the total ownership interests."
|
||||
))
|
||||
|
||||
_add_section(doc, "6.3", "Action Without Meeting", (
|
||||
"Any action that may be taken at a meeting of the Members may be taken "
|
||||
"without a meeting if the action is consented to in writing by Members "
|
||||
"holding sufficient ownership interests to authorize such action at a meeting."
|
||||
))
|
||||
|
||||
_add_section(doc, "6.4", "Voting", (
|
||||
"Each Member shall be entitled to vote in proportion to their ownership "
|
||||
"interest. Except as otherwise provided in this Agreement, decisions "
|
||||
"shall be made by a majority vote of the total ownership interests."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE VII — TRANSFER OF MEMBERSHIP INTERESTS
|
||||
# ======================================================================
|
||||
_add_article(doc, "VII", "TRANSFER OF MEMBERSHIP INTERESTS")
|
||||
|
||||
_add_section(doc, "7.1", "Restrictions on Transfer", (
|
||||
"No Member may sell, assign, pledge, or otherwise transfer all or any "
|
||||
"portion of their membership interest without the prior written consent "
|
||||
"of all other Members."
|
||||
))
|
||||
|
||||
_add_section(doc, "7.2", "Right of First Refusal", (
|
||||
"Before any Member may transfer their interest to a non-Member, the "
|
||||
"transferring Member shall first offer the interest to the remaining "
|
||||
"Members, pro rata, at the same price and on the same terms as the "
|
||||
"proposed transfer. The remaining Members shall have thirty (30) days "
|
||||
"to accept or decline the offer."
|
||||
))
|
||||
|
||||
_add_section(doc, "7.3", "Permitted Transfers", (
|
||||
"Notwithstanding the foregoing, a Member may transfer their interest "
|
||||
"to a revocable trust established by such Member for estate planning "
|
||||
"purposes, provided that the transferring Member remains the trustee "
|
||||
"or retains control of the trust."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE VIII — DISSOLUTION
|
||||
# ======================================================================
|
||||
_add_article(doc, "VIII", "DISSOLUTION")
|
||||
|
||||
_add_section(doc, "8.1", "Events of Dissolution", (
|
||||
"The Company shall be dissolved upon the occurrence of any of the following: "
|
||||
"(a) the unanimous written agreement of all Members; "
|
||||
"(b) entry of a decree of judicial dissolution; "
|
||||
"(c) any event that makes it unlawful for the Company to continue its business; "
|
||||
f"(d) as otherwise required by the laws of the State of {state_name}."
|
||||
))
|
||||
|
||||
_add_section(doc, "8.2", "Winding Up", (
|
||||
"Upon dissolution, the Company's affairs shall be wound up. The assets "
|
||||
"shall be liquidated and the proceeds applied in the following order: "
|
||||
"(a) payment of debts and liabilities to creditors; "
|
||||
"(b) payment of debts and liabilities to Members; "
|
||||
"(c) distribution to Members in accordance with their positive capital "
|
||||
"account balances."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE IX — MISCELLANEOUS
|
||||
# ======================================================================
|
||||
_add_article(doc, "IX", "MISCELLANEOUS")
|
||||
|
||||
_add_section(doc, "9.1", "Governing Law", (
|
||||
f"This Agreement shall be governed by and construed in accordance with "
|
||||
f"the laws of the State of {state_name}, without regard to its conflict "
|
||||
f"of laws principles."
|
||||
))
|
||||
|
||||
_add_section(doc, "9.2", "Amendments", (
|
||||
"This Agreement may be amended only by a written instrument signed by "
|
||||
"all Members."
|
||||
))
|
||||
|
||||
_add_section(doc, "9.3", "Severability", (
|
||||
"If any provision of this Agreement is held to be invalid, illegal, or "
|
||||
"unenforceable, the remaining provisions shall continue in full force "
|
||||
"and effect."
|
||||
))
|
||||
|
||||
_add_section(doc, "9.4", "Entire Agreement", (
|
||||
"This Agreement constitutes the entire agreement among the Members with "
|
||||
"respect to the subject matter hereof and supersedes all prior agreements, "
|
||||
"understandings, negotiations, and discussions."
|
||||
))
|
||||
|
||||
_add_section(doc, "9.5", "Binding Effect", (
|
||||
"This Agreement shall be binding upon and inure to the benefit of the "
|
||||
"Members and their respective heirs, executors, administrators, "
|
||||
"successors, and permitted assigns."
|
||||
))
|
||||
|
||||
_add_section(doc, "9.6", "Indemnification", (
|
||||
"The Company shall indemnify and hold harmless each Member and Manager "
|
||||
"from and against any and all claims, liabilities, damages, and expenses "
|
||||
"(including reasonable attorneys' fees) arising out of or relating to "
|
||||
"the Company's business, except to the extent caused by such person's "
|
||||
"gross negligence or willful misconduct."
|
||||
))
|
||||
|
||||
# ======================================================================
|
||||
# ARTICLE X — SIGNATURE BLOCK
|
||||
# ======================================================================
|
||||
_add_article(doc, "X", "EXECUTION")
|
||||
|
||||
doc.add_paragraph(
|
||||
"IN WITNESS WHEREOF, the undersigned Members have executed this "
|
||||
f"Operating Agreement as of {formation_date}."
|
||||
)
|
||||
doc.add_paragraph() # spacer
|
||||
|
||||
# Signature lines for each member
|
||||
for member in order.members:
|
||||
sig_block = doc.add_paragraph()
|
||||
sig_block.add_run("\n")
|
||||
sig_block.add_run("_" * 50)
|
||||
sig_block.add_run("\n")
|
||||
name_run = sig_block.add_run(member.name)
|
||||
name_run.font.bold = True
|
||||
sig_block.add_run(f"\n{member.title}")
|
||||
sig_block.add_run(f"\nOwnership: {member.ownership_pct:.1f}%")
|
||||
sig_block.add_run("\nDate: _________________")
|
||||
doc.add_paragraph() # spacer between signature blocks
|
||||
|
||||
# If no members listed, add a generic signature block
|
||||
if not order.members:
|
||||
sig_block = doc.add_paragraph()
|
||||
sig_block.add_run("\n")
|
||||
sig_block.add_run("_" * 50)
|
||||
sig_block.add_run("\nMember Name: _________________________")
|
||||
sig_block.add_run("\nTitle: _______________________________")
|
||||
sig_block.add_run("\nDate: ________________________________")
|
||||
|
||||
# -- Save .docx --
|
||||
doc.save(str(docx_path))
|
||||
LOG.info("Operating agreement .docx saved: %s", docx_path)
|
||||
|
||||
# -- Convert to PDF --
|
||||
try:
|
||||
from docx2pdf import convert
|
||||
convert(str(docx_path), str(pdf_path))
|
||||
LOG.info("Operating agreement .pdf saved: %s", pdf_path)
|
||||
except ImportError:
|
||||
LOG.warning(
|
||||
"docx2pdf not available — PDF conversion skipped. "
|
||||
"Install with: pip install docx2pdf"
|
||||
)
|
||||
# Attempt LibreOffice fallback
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[
|
||||
"libreoffice",
|
||||
"--headless",
|
||||
"--convert-to", "pdf",
|
||||
"--outdir", str(output_dir),
|
||||
str(docx_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and pdf_path.exists():
|
||||
LOG.info("Operating agreement .pdf saved (LibreOffice): %s", pdf_path)
|
||||
else:
|
||||
LOG.warning("LibreOffice conversion failed: %s", result.stderr)
|
||||
pdf_path = Path("") # No PDF available
|
||||
except FileNotFoundError:
|
||||
LOG.warning(
|
||||
"Neither docx2pdf nor LibreOffice available for PDF conversion."
|
||||
)
|
||||
pdf_path = Path("")
|
||||
except Exception as exc:
|
||||
LOG.error("PDF conversion failed: %s", exc)
|
||||
pdf_path = Path("")
|
||||
|
||||
return str(docx_path), str(pdf_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_article(doc: Document, number: str, title: str):
|
||||
"""Add an article heading."""
|
||||
heading = doc.add_heading(f"ARTICLE {number} — {title}", level=2)
|
||||
heading.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
|
||||
|
||||
def _add_section(doc: Document, number: str, title: str, text: str):
|
||||
"""Add a numbered section with bold title and body text."""
|
||||
para = doc.add_paragraph()
|
||||
run_num = para.add_run(f"Section {number}. {title}. ")
|
||||
run_num.font.bold = True
|
||||
para.add_run(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
"""Generate an operating agreement from a formation order in the database."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m formation.operating_agreement <order_id>")
|
||||
print()
|
||||
print("Generates an LLC operating agreement (.docx and .pdf)")
|
||||
print("from the formation order data in the database.")
|
||||
sys.exit(1)
|
||||
|
||||
order_id = sys.argv[1]
|
||||
database_url = os.environ.get("DATABASE_URL", "")
|
||||
if not database_url:
|
||||
print("Error: DATABASE_URL not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
conn = psycopg2.connect(database_url)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
print(f"Error: Order {order_id} not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build FormationOrder
|
||||
members_raw = row.get("members")
|
||||
if isinstance(members_raw, str):
|
||||
members_raw = json.loads(members_raw)
|
||||
elif members_raw is None:
|
||||
members_raw = []
|
||||
|
||||
members = [
|
||||
Member(
|
||||
name=m.get("name", ""),
|
||||
address=m.get("address", ""),
|
||||
city=m.get("city", ""),
|
||||
state=m.get("state", ""),
|
||||
zip_code=m.get("zip_code", ""),
|
||||
title=m.get("title", "Member"),
|
||||
ownership_pct=float(m.get("ownership_pct", 0)),
|
||||
is_organizer=bool(m.get("is_organizer", False)),
|
||||
)
|
||||
for m in members_raw
|
||||
]
|
||||
|
||||
try:
|
||||
entity_type = EntityType(row.get("entity_type", "llc"))
|
||||
except ValueError:
|
||||
entity_type = EntityType.LLC
|
||||
|
||||
order = FormationOrder(
|
||||
order_id=str(row["order_id"]),
|
||||
state_code=row.get("state_code", ""),
|
||||
entity_type=entity_type,
|
||||
entity_name=row.get("entity_name", ""),
|
||||
management_type=row.get("management_type", "member_managed"),
|
||||
purpose=row.get("purpose", "Any lawful business activity"),
|
||||
members=members,
|
||||
registered_agent_name=row.get("registered_agent_name", "Northwest Registered Agent"),
|
||||
registered_agent_address=row.get("registered_agent_address", ""),
|
||||
principal_address=row.get("principal_address", ""),
|
||||
principal_city=row.get("principal_city", ""),
|
||||
principal_state=row.get("principal_state", ""),
|
||||
principal_zip=row.get("principal_zip", ""),
|
||||
fiscal_year_end=row.get("fiscal_year_end", "12/31"),
|
||||
effective_date=row.get("effective_date", "") or "",
|
||||
filed_at=row.get("filed_at", "") or "",
|
||||
)
|
||||
|
||||
docx_path, pdf_path = generate_operating_agreement(order)
|
||||
print(f"Generated operating agreement:")
|
||||
print(f" DOCX: {docx_path}")
|
||||
print(f" PDF: {pdf_path or '(not available — install docx2pdf or libreoffice)'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
232
scripts/formation/portal_schedule.py
Normal file
232
scripts/formation/portal_schedule.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""
|
||||
Portal Schedule — business hours awareness for state/provincial portals.
|
||||
|
||||
Some portals restrict filing hours. Attempting automation outside these windows
|
||||
results in portal-unavailable errors, which are hard to distinguish from real failures.
|
||||
This module provides a consistent interface to check availability and compute
|
||||
the next open window so the job server can defer rather than fail.
|
||||
|
||||
Known restricted portals:
|
||||
BC Corporate Online: Mon-Sat 06:00-22:00 PT, Sun 13:00-22:00 PT
|
||||
IRS EIN Assistant: Mon-Fri 07:00-22:00 ET
|
||||
All others: 24/7 (None schedule = always available)
|
||||
|
||||
Usage:
|
||||
schedule = PortalSchedule.from_config(config["portal_schedule"])
|
||||
available, next_open = schedule.is_available()
|
||||
if not available:
|
||||
job.defer_until(next_open)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOG = logging.getLogger("formation.portal_schedule")
|
||||
|
||||
# Day index: 0=Monday ... 6=Sunday (matches datetime.weekday())
|
||||
DAY_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayWindow:
|
||||
"""Open/close hours for a single day of the week. None = closed all day."""
|
||||
open_hour: int # 0-23 inclusive
|
||||
close_hour: int # 0-23 inclusive (exclusive end — portal closes AT this hour)
|
||||
|
||||
|
||||
# Pre-built schedule configs for known restricted portals
|
||||
BC_CORPORATE_ONLINE_SCHEDULE = {
|
||||
"timezone": "America/Vancouver",
|
||||
"jurisdiction": "BC",
|
||||
"closed_holidays": True,
|
||||
"hours": {
|
||||
"mon": [6, 22],
|
||||
"tue": [6, 22],
|
||||
"wed": [6, 22],
|
||||
"thu": [6, 22],
|
||||
"fri": [6, 22],
|
||||
"sat": [6, 22],
|
||||
"sun": [13, 22],
|
||||
},
|
||||
}
|
||||
|
||||
IRS_EIN_SCHEDULE = {
|
||||
"timezone": "America/New_York",
|
||||
"jurisdiction": "IRS",
|
||||
"closed_holidays": True,
|
||||
"hours": {
|
||||
"mon": [7, 22],
|
||||
"tue": [7, 22],
|
||||
"wed": [7, 22],
|
||||
"thu": [7, 22],
|
||||
"fri": [7, 22],
|
||||
"sat": None, # Closed
|
||||
"sun": None, # Closed
|
||||
},
|
||||
}
|
||||
|
||||
# Standard US state SOS portal — Mon-Fri 7am-11pm ET, closed weekends & federal holidays
|
||||
US_STATE_SOS_SCHEDULE = {
|
||||
"timezone": "America/New_York",
|
||||
"jurisdiction": "US",
|
||||
"closed_holidays": True,
|
||||
"hours": {
|
||||
"mon": [7, 23],
|
||||
"tue": [7, 23],
|
||||
"wed": [7, 23],
|
||||
"thu": [7, 23],
|
||||
"fri": [7, 23],
|
||||
"sat": None,
|
||||
"sun": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortalSchedule:
|
||||
"""
|
||||
Defines the business hours of a filing portal.
|
||||
|
||||
Attributes:
|
||||
timezone: IANA timezone string (e.g. 'America/Vancouver')
|
||||
hours: Dict mapping day name to [open, close] hours or None if closed.
|
||||
jurisdiction: Holiday jurisdiction code: 'US', 'CA', 'BC', 'IRS', or None (no holiday check).
|
||||
closed_holidays: If True (default), treat holidays as closed days even if hours are defined.
|
||||
"""
|
||||
timezone: str
|
||||
hours: dict[str, Optional[list[int]]] # day -> [open_hour, close_hour] | None
|
||||
jurisdiction: Optional[str] = None # 'US', 'CA', 'BC', 'IRS'
|
||||
closed_holidays: bool = True
|
||||
|
||||
@classmethod
|
||||
def always_open(cls) -> "PortalSchedule":
|
||||
"""Return a schedule that is always available (24/7 portals, no holidays)."""
|
||||
return cls(
|
||||
timezone="UTC",
|
||||
hours={d: [0, 24] for d in DAY_NAMES},
|
||||
jurisdiction=None,
|
||||
closed_holidays=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: Optional[dict]) -> "PortalSchedule":
|
||||
"""
|
||||
Build a PortalSchedule from a config dict.
|
||||
If config is None, returns an always-open schedule (24/7 portal).
|
||||
|
||||
Config keys:
|
||||
timezone: IANA timezone (default: 'UTC')
|
||||
hours: day -> [open, close] | None
|
||||
jurisdiction: 'US' | 'CA' | 'BC' | 'IRS' | None
|
||||
closed_holidays: bool (default True)
|
||||
"""
|
||||
if config is None:
|
||||
return cls.always_open()
|
||||
return cls(
|
||||
timezone=config.get("timezone", "UTC"),
|
||||
hours=config.get("hours", {d: [0, 24] for d in DAY_NAMES}),
|
||||
jurisdiction=config.get("jurisdiction"),
|
||||
closed_holidays=config.get("closed_holidays", True),
|
||||
)
|
||||
|
||||
def _is_holiday_today(self, local_date) -> bool:
|
||||
"""Check if the local date is a holiday in our jurisdiction."""
|
||||
if not self.closed_holidays or not self.jurisdiction:
|
||||
return False
|
||||
try:
|
||||
from scripts.formation.holidays import is_holiday as _is_holiday
|
||||
return _is_holiday(local_date, jurisdiction=self.jurisdiction)
|
||||
except ImportError:
|
||||
LOG.warning("holidays module not available — skipping holiday check")
|
||||
return False
|
||||
|
||||
def _holiday_name(self, local_date) -> Optional[str]:
|
||||
"""Get the name of the holiday if it is one."""
|
||||
if not self.closed_holidays or not self.jurisdiction:
|
||||
return None
|
||||
try:
|
||||
from scripts.formation.holidays import holiday_name as _holiday_name
|
||||
return _holiday_name(local_date, jurisdiction=self.jurisdiction)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def is_available(self, at: Optional[datetime] = None) -> tuple[bool, Optional[datetime]]:
|
||||
"""
|
||||
Check if the portal is currently available.
|
||||
|
||||
Checks:
|
||||
1. Holiday calendar (jurisdiction-aware)
|
||||
2. Day-of-week business hours
|
||||
|
||||
Args:
|
||||
at: datetime to check (defaults to now). Should be timezone-naive UTC or tz-aware.
|
||||
|
||||
Returns:
|
||||
(available: bool, next_open: datetime | None)
|
||||
next_open is UTC datetime of the next opening time (None if currently open).
|
||||
"""
|
||||
tz = ZoneInfo(self.timezone)
|
||||
now_utc = at or datetime.utcnow().replace(tzinfo=ZoneInfo("UTC"))
|
||||
if now_utc.tzinfo is None:
|
||||
now_utc = now_utc.replace(tzinfo=ZoneInfo("UTC"))
|
||||
now_local = now_utc.astimezone(tz)
|
||||
|
||||
# Holiday check — closed all day
|
||||
if self._is_holiday_today(now_local.date()):
|
||||
hname = self._holiday_name(now_local.date()) or "holiday"
|
||||
LOG.info(f"Portal closed for {hname} ({now_local.date()})")
|
||||
next_open = self._next_open_after(now_local, tz)
|
||||
return False, next_open
|
||||
|
||||
day_name = DAY_NAMES[now_local.weekday()]
|
||||
window = self.hours.get(day_name)
|
||||
|
||||
if window is not None:
|
||||
open_h, close_h = window[0], window[1]
|
||||
if open_h <= now_local.hour < close_h:
|
||||
return True, None # Currently open
|
||||
|
||||
# Not currently available — find next open window
|
||||
next_open = self._next_open_after(now_local, tz)
|
||||
return False, next_open
|
||||
|
||||
def _next_open_after(self, now_local: datetime, tz: ZoneInfo) -> datetime:
|
||||
"""Find the next datetime (in UTC) when the portal opens, skipping holidays."""
|
||||
# Search up to 14 days ahead (handles multi-day holiday stretches like Christmas week)
|
||||
candidate = now_local.replace(minute=0, second=0, microsecond=0)
|
||||
for _ in range(14 * 24): # hourly steps, 14 days max
|
||||
candidate += timedelta(hours=1)
|
||||
|
||||
# Skip holidays
|
||||
if self._is_holiday_today(candidate.date()):
|
||||
continue
|
||||
|
||||
day_name = DAY_NAMES[candidate.weekday()]
|
||||
window = self.hours.get(day_name)
|
||||
if window is not None:
|
||||
open_h, close_h = window[0], window[1]
|
||||
if open_h <= candidate.hour < close_h:
|
||||
# Add small random offset (0-5 min) to avoid thundering herd
|
||||
jitter = timedelta(seconds=random.randint(0, 300))
|
||||
return candidate.astimezone(ZoneInfo("UTC")) + jitter
|
||||
|
||||
# Fallback: 24 hours from now (should never hit this)
|
||||
LOG.warning("Could not find next open window within 14 days — deferring 24h")
|
||||
return (now_local + timedelta(hours=24)).astimezone(ZoneInfo("UTC"))
|
||||
|
||||
def minutes_until_open(self, at: Optional[datetime] = None) -> Optional[int]:
|
||||
"""Return minutes until next open, or None if currently open."""
|
||||
available, next_open = self.is_available(at)
|
||||
if available or next_open is None:
|
||||
return None
|
||||
now_utc = (at or datetime.utcnow()).replace(tzinfo=ZoneInfo("UTC"))
|
||||
if now_utc.tzinfo is None:
|
||||
now_utc = now_utc.replace(tzinfo=ZoneInfo("UTC"))
|
||||
delta = next_open - now_utc
|
||||
return max(0, int(delta.total_seconds() / 60))
|
||||
96
scripts/formation/states/__init__.py
Normal file
96
scripts/formation/states/__init__.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""
|
||||
State adapter registry.
|
||||
|
||||
Maps 2-letter state codes to their adapter modules.
|
||||
Each state directory contains:
|
||||
- config.py — Portal URLs, NW RA address, selectors, fees
|
||||
- adapter.py — StatePortal subclass with Playwright automation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scripts.formation.base import StatePortal
|
||||
|
||||
# State metadata for the registry
|
||||
STATES = {
|
||||
"AL": {"name": "Alabama", "search_method": "playwright"},
|
||||
"AK": {"name": "Alaska", "search_method": "socrata"},
|
||||
"AZ": {"name": "Arizona", "search_method": "playwright"},
|
||||
"AR": {"name": "Arkansas", "search_method": "playwright"},
|
||||
"CA": {"name": "California", "search_method": "playwright"},
|
||||
"CO": {"name": "Colorado", "search_method": "socrata_api"},
|
||||
"CT": {"name": "Connecticut", "search_method": "socrata"},
|
||||
"DE": {"name": "Delaware", "search_method": "playwright"},
|
||||
"FL": {"name": "Florida", "search_method": "sftp_bulk"},
|
||||
"GA": {"name": "Georgia", "search_method": "playwright"},
|
||||
"HI": {"name": "Hawaii", "search_method": "playwright"},
|
||||
"ID": {"name": "Idaho", "search_method": "playwright"},
|
||||
"IL": {"name": "Illinois", "search_method": "socrata"},
|
||||
"IN": {"name": "Indiana", "search_method": "playwright"},
|
||||
"IA": {"name": "Iowa", "search_method": "socrata"},
|
||||
"KS": {"name": "Kansas", "search_method": "playwright"},
|
||||
"KY": {"name": "Kentucky", "search_method": "playwright"},
|
||||
"LA": {"name": "Louisiana", "search_method": "playwright"},
|
||||
"ME": {"name": "Maine", "search_method": "playwright"},
|
||||
"MD": {"name": "Maryland", "search_method": "playwright"},
|
||||
"MA": {"name": "Massachusetts", "search_method": "playwright"},
|
||||
"MI": {"name": "Michigan", "search_method": "socrata"},
|
||||
"MN": {"name": "Minnesota", "search_method": "playwright"},
|
||||
"MS": {"name": "Mississippi", "search_method": "playwright"},
|
||||
"MO": {"name": "Missouri", "search_method": "playwright"},
|
||||
"MT": {"name": "Montana", "search_method": "playwright"},
|
||||
"NE": {"name": "Nebraska", "search_method": "playwright"},
|
||||
"NV": {"name": "Nevada", "search_method": "playwright"},
|
||||
"NH": {"name": "New Hampshire", "search_method": "playwright"},
|
||||
"NJ": {"name": "New Jersey", "search_method": "playwright"},
|
||||
"NM": {"name": "New Mexico", "search_method": "playwright"},
|
||||
"NY": {"name": "New York", "search_method": "socrata"},
|
||||
"NC": {"name": "North Carolina", "search_method": "playwright"},
|
||||
"ND": {"name": "North Dakota", "search_method": "playwright"},
|
||||
"OH": {"name": "Ohio", "search_method": "playwright"},
|
||||
"OK": {"name": "Oklahoma", "search_method": "playwright"},
|
||||
"OR": {"name": "Oregon", "search_method": "socrata"},
|
||||
"PA": {"name": "Pennsylvania", "search_method": "socrata"},
|
||||
"RI": {"name": "Rhode Island", "search_method": "playwright"},
|
||||
"SC": {"name": "South Carolina", "search_method": "playwright"},
|
||||
"SD": {"name": "South Dakota", "search_method": "playwright"},
|
||||
"TN": {"name": "Tennessee", "search_method": "playwright"},
|
||||
"TX": {"name": "Texas", "search_method": "playwright"},
|
||||
"UT": {"name": "Utah", "search_method": "playwright"},
|
||||
"VT": {"name": "Vermont", "search_method": "socrata"},
|
||||
"VA": {"name": "Virginia", "search_method": "playwright"},
|
||||
"WA": {"name": "Washington", "search_method": "socrata"},
|
||||
"WV": {"name": "West Virginia", "search_method": "playwright"},
|
||||
"WI": {"name": "Wisconsin", "search_method": "playwright"},
|
||||
"WY": {"name": "Wyoming", "search_method": "playwright"},
|
||||
"DC": {"name": "District of Columbia", "search_method": "playwright"},
|
||||
# Canadian provinces
|
||||
"BC": {"name": "British Columbia", "search_method": "playwright"},
|
||||
"ON": {"name": "Ontario", "search_method": "playwright"},
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(state_code: str) -> "StatePortal":
|
||||
"""Dynamically import and return the adapter for a state."""
|
||||
code = state_code.upper()
|
||||
if code not in STATES:
|
||||
raise ValueError(f"Unknown state code: {code}")
|
||||
|
||||
module_name = f".{code.lower()}.adapter"
|
||||
import importlib
|
||||
mod = importlib.import_module(module_name, package=__name__)
|
||||
return mod.adapter()
|
||||
|
||||
|
||||
def get_config(state_code: str) -> dict:
|
||||
"""Return the config dict for a state."""
|
||||
code = state_code.upper()
|
||||
if code not in STATES:
|
||||
raise ValueError(f"Unknown state code: {code}")
|
||||
|
||||
module_name = f".{code.lower()}.config"
|
||||
import importlib
|
||||
mod = importlib.import_module(module_name, package=__name__)
|
||||
return mod.CONFIG
|
||||
2
scripts/formation/states/ak/__init__.py
Normal file
2
scripts/formation/states/ak/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ak/adapter.py
Normal file
118
scripts/formation/states/ak/adapter.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Alaska — CBPL portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class AKPortal(StatePortal):
|
||||
STATE_CODE = "AK"
|
||||
STATE_NAME = "Alaska"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Alaska business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Alaska."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Alaska-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Alaska",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Alaska."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Alaska",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> AKPortal:
|
||||
return AKPortal()
|
||||
49
scripts/formation/states/ak/config.py
Normal file
49
scripts/formation/states/ak/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Alaska — Corporations, Business and Professional Licensing portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "AK",
|
||||
"state_name": "Alaska",
|
||||
"sos_name": "Alaska Division of Corporations, Business and Professional Licensing",
|
||||
"portal_name": "Alaska CBPL Entity Search",
|
||||
"portal_url": "https://commerce.alaska.gov",
|
||||
"name_search_url": "https://commerce.alaska.gov/cbp/main/search/entities",
|
||||
"filing_url": "https://commerce.alaska.gov/cbp/main/search/entities",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "3000 A St Ste 200",
|
||||
"nwra_city": "Anchorage",
|
||||
"nwra_state": "AK",
|
||||
"nwra_zip": "99503",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 25000,
|
||||
"corp_formation_fee": 25000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/al/__init__.py
Normal file
2
scripts/formation/states/al/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/al/adapter.py
Normal file
118
scripts/formation/states/al/adapter.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Alabama — SOS portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class ALPortal(StatePortal):
|
||||
STATE_CODE = "AL"
|
||||
STATE_NAME = "Alabama"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Alabama business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Alabama."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Alabama-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Alabama",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Alabama."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Alabama",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> ALPortal:
|
||||
return ALPortal()
|
||||
49
scripts/formation/states/al/config.py
Normal file
49
scripts/formation/states/al/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Alabama — Secretary of State portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "AL",
|
||||
"state_name": "Alabama",
|
||||
"sos_name": "Alabama Secretary of State",
|
||||
"portal_name": "Alabama Business Entity Records",
|
||||
"portal_url": "https://sos.alabama.gov",
|
||||
"name_search_url": "https://sos.alabama.gov/government-records/business-entity-records",
|
||||
"filing_url": "https://sos.alabama.gov/government-records/business-entity-records",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "100 Centerview Dr Ste 115",
|
||||
"nwra_city": "Birmingham",
|
||||
"nwra_state": "AL",
|
||||
"nwra_zip": "35216",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 20000,
|
||||
"corp_formation_fee": 20000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/ar/__init__.py
Normal file
2
scripts/formation/states/ar/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ar/adapter.py
Normal file
118
scripts/formation/states/ar/adapter.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Arkansas — SOS portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class ARPortal(StatePortal):
|
||||
STATE_CODE = "AR"
|
||||
STATE_NAME = "Arkansas"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Arkansas business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Arkansas."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Arkansas-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Arkansas",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Arkansas."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Arkansas",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> ARPortal:
|
||||
return ARPortal()
|
||||
49
scripts/formation/states/ar/config.py
Normal file
49
scripts/formation/states/ar/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Arkansas — Secretary of State portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "AR",
|
||||
"state_name": "Arkansas",
|
||||
"sos_name": "Arkansas Secretary of State",
|
||||
"portal_name": "Arkansas Business Entity Search",
|
||||
"portal_url": "https://sos.arkansas.gov",
|
||||
"name_search_url": "https://biz.sos.arkansas.gov/search",
|
||||
"filing_url": "https://biz.sos.arkansas.gov/search",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "1321 Scott St",
|
||||
"nwra_city": "Little Rock",
|
||||
"nwra_state": "AR",
|
||||
"nwra_zip": "72202",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 4500,
|
||||
"corp_formation_fee": 4500,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/az/__init__.py
Normal file
2
scripts/formation/states/az/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
119
scripts/formation/states/az/adapter.py
Normal file
119
scripts/formation/states/az/adapter.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Arizona — ACC portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class AZPortal(StatePortal):
|
||||
STATE_CODE = "AZ"
|
||||
STATE_NAME = "Arizona"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Arizona business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Arizona."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Arizona-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
# NOTE: Arizona requires publication after formation.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Arizona",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Arizona."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Arizona",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> AZPortal:
|
||||
return AZPortal()
|
||||
49
scripts/formation/states/az/config.py
Normal file
49
scripts/formation/states/az/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Arizona — Arizona Corporation Commission portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "AZ",
|
||||
"state_name": "Arizona",
|
||||
"sos_name": "Arizona Corporation Commission",
|
||||
"portal_name": "ACC eCorp Entity Search",
|
||||
"portal_url": "https://azcc.gov",
|
||||
"name_search_url": "https://ecorp.azcc.gov/EntitySearch/Index",
|
||||
"filing_url": "https://ecorp.azcc.gov/EntitySearch/Index",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "8700 E Vista Bonita Dr Ste 268",
|
||||
"nwra_city": "Scottsdale",
|
||||
"nwra_state": "AZ",
|
||||
"nwra_zip": "85255",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 5000,
|
||||
"corp_formation_fee": 6000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "Publication required. After formation, Articles of Organization must be published in an approved newspaper within 60 days.",
|
||||
}
|
||||
4
scripts/formation/states/bc/__init__.py
Normal file
4
scripts/formation/states/bc/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .config import CONFIG
|
||||
from .adapter import BCPortal
|
||||
|
||||
__all__ = ["CONFIG", "BCPortal"]
|
||||
977
scripts/formation/states/bc/adapter.py
Normal file
977
scripts/formation/states/bc/adapter.py
Normal file
|
|
@ -0,0 +1,977 @@
|
|||
"""
|
||||
British Columbia — Corporate Online / BC Registry adapter.
|
||||
|
||||
Automates:
|
||||
1. Anytime Mailbox setup (BC registered office) via anytimemailbox.com
|
||||
2. Name search & reservation via bcregistrynames.gov.bc.ca
|
||||
3. Incorporation filing via corporateonline.gov.bc.ca
|
||||
4. .ca domain + email + web presence provisioning (HestiaCP)
|
||||
5. Canadian phone number provisioning
|
||||
6. Corporate binder compilation (DOCX → PDF)
|
||||
7. Business banking link delivery
|
||||
8. CRTC registration letter generation (Voice, Data & Wireless Reseller)
|
||||
9. CCTS registration
|
||||
|
||||
All Playwright methods are structural stubs — CSS selectors in config.py
|
||||
must be populated after manual portal inspection before going live.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import asyncio
|
||||
import imaplib
|
||||
import email
|
||||
from email.header import decode_header, make_header
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from scripts.formation.base import (
|
||||
FilingResult,
|
||||
FilingStatus,
|
||||
FormationOrder,
|
||||
NameSearchResult,
|
||||
StatePortal,
|
||||
)
|
||||
from .config import CONFIG
|
||||
|
||||
LOG = logging.getLogger("formation.bc")
|
||||
|
||||
# Steps with open selector verification gaps from live Corporate Online flow.
|
||||
COLIN_UNVERIFIED_STEP_SELECTORS = {
|
||||
6: ["inc_director_name", "inc_director_address"],
|
||||
7: ["inc_share_structure"],
|
||||
8: ["inc_articles"],
|
||||
9: ["pay_card_number", "pay_card_exp", "pay_card_cvv", "pay_card_name", "pay_submit"],
|
||||
12: ["inc_submit"],
|
||||
}
|
||||
|
||||
# DOCX template for CRTC letter (in templates/ directory)
|
||||
CRTC_TEMPLATE = os.getenv(
|
||||
"CRTC_LETTER_TEMPLATE",
|
||||
str(Path(__file__).resolve().parent.parent.parent.parent / "templates" / "crtc_notification_letter.docx"),
|
||||
)
|
||||
|
||||
|
||||
class BCPortal(StatePortal):
|
||||
"""Adapter for BC Registry Services (Corporate Online) and Anytime Mailbox."""
|
||||
|
||||
STATE_CODE = "BC"
|
||||
STATE_NAME = "British Columbia"
|
||||
PORTAL_NAME = "Corporate Online"
|
||||
PORTAL_URL = CONFIG["filing_portal"]["url"]
|
||||
|
||||
SUPPORTS_LLC = False # Canada has no LLC entity type
|
||||
SUPPORTS_CORP = True
|
||||
SUPPORTS_ONLINE_FILING = True
|
||||
SUPPORTS_NAME_SEARCH = True
|
||||
|
||||
# No NW Registered Agent in Canada — we use Anytime Mailbox instead
|
||||
NWRA_ADDRESS = ""
|
||||
NWRA_CITY = ""
|
||||
NWRA_STATE = ""
|
||||
NWRA_ZIP = ""
|
||||
|
||||
CONFIG = CONFIG
|
||||
|
||||
def _missing_selectors(self, keys: list[str]) -> list[str]:
|
||||
selectors = CONFIG["selectors"]
|
||||
return [k for k in keys if not str(selectors.get(k, "")).strip()]
|
||||
|
||||
def _missing_colin_step_map(self) -> dict[int, list[str]]:
|
||||
missing: dict[int, list[str]] = {}
|
||||
for step, keys in COLIN_UNVERIFIED_STEP_SELECTORS.items():
|
||||
gaps = self._missing_selectors(keys)
|
||||
if gaps:
|
||||
missing[step] = gaps
|
||||
return missing
|
||||
|
||||
def _decode_header_value(self, raw: str) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
return str(make_header(decode_header(raw)))
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
def _extract_otp_candidate(self, text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
match = re.search(r"(?:verification|security|one[-\s]?time|otp|code)[^\d]{0,30}(\d{6})", text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
fallback = re.search(r"\b(\d{6})\b", text)
|
||||
return fallback.group(1) if fallback else ""
|
||||
|
||||
def _fetch_anytime_otp_sync(self, expected_recipient: str) -> str:
|
||||
imap_host = os.getenv("ANYTIME_MAILBOX_IMAP_HOST", os.getenv("RELAY_IMAP_HOST", "mail.performancewest.net"))
|
||||
imap_port = int(os.getenv("ANYTIME_MAILBOX_IMAP_PORT", os.getenv("RELAY_IMAP_PORT", "993")))
|
||||
imap_ssl = os.getenv("ANYTIME_MAILBOX_IMAP_SSL", "true").lower() == "true"
|
||||
imap_user = os.getenv("ANYTIME_MAILBOX_IMAP_USER", "").strip()
|
||||
imap_pass = os.getenv("ANYTIME_MAILBOX_IMAP_PASS", "").strip()
|
||||
imap_folder = os.getenv("ANYTIME_MAILBOX_IMAP_FOLDER", "INBOX")
|
||||
sender_hint = os.getenv("ANYTIME_MAILBOX_OTP_SENDER_HINT", "anytimemailbox")
|
||||
|
||||
if not imap_user or not imap_pass:
|
||||
self.log.warning("Anytime OTP auto-fetch disabled: IMAP credentials missing")
|
||||
return ""
|
||||
|
||||
client: imaplib.IMAP4 | imaplib.IMAP4_SSL
|
||||
client = imaplib.IMAP4_SSL(imap_host, imap_port) if imap_ssl else imaplib.IMAP4(imap_host, imap_port)
|
||||
try:
|
||||
client.login(imap_user, imap_pass)
|
||||
client.select(imap_folder)
|
||||
status, data = client.search(None, "ALL")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return ""
|
||||
|
||||
msg_ids = data[0].split()[-40:]
|
||||
for msg_id in reversed(msg_ids):
|
||||
fetch_status, parts = client.fetch(msg_id, "(RFC822)")
|
||||
if fetch_status != "OK" or not parts:
|
||||
continue
|
||||
raw = parts[0][1] if isinstance(parts[0], tuple) and len(parts[0]) > 1 else b""
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
msg = email.message_from_bytes(raw)
|
||||
subj = self._decode_header_value(msg.get("Subject", ""))
|
||||
from_addr = self._decode_header_value(msg.get("From", ""))
|
||||
to_addr = self._decode_header_value(msg.get("To", ""))
|
||||
|
||||
envelope = f"{subj}\n{from_addr}\n{to_addr}"
|
||||
if sender_hint.lower() not in envelope.lower() and "verification" not in envelope.lower():
|
||||
continue
|
||||
if expected_recipient and expected_recipient.lower() not in to_addr.lower() and expected_recipient.lower() not in envelope.lower():
|
||||
continue
|
||||
|
||||
body_text = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ctype = part.get_content_type()
|
||||
if ctype in ("text/plain", "text/html"):
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
try:
|
||||
body_text += payload.decode(part.get_content_charset() or "utf-8", errors="ignore") + "\n"
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
payload = msg.get_payload(decode=True) or b""
|
||||
body_text = payload.decode(msg.get_content_charset() or "utf-8", errors="ignore")
|
||||
|
||||
otp = self._extract_otp_candidate(f"{envelope}\n{body_text}")
|
||||
if otp:
|
||||
return otp
|
||||
|
||||
return ""
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _wait_for_anytime_otp(self, expected_recipient: str) -> str:
|
||||
timeout_s = int(os.getenv("ANYTIME_MAILBOX_OTP_TIMEOUT_SECONDS", "180"))
|
||||
poll_s = int(os.getenv("ANYTIME_MAILBOX_OTP_POLL_SECONDS", "6"))
|
||||
elapsed = 0
|
||||
while elapsed <= timeout_s:
|
||||
otp = await asyncio.to_thread(self._fetch_anytime_otp_sync, expected_recipient)
|
||||
if otp:
|
||||
return otp
|
||||
await asyncio.sleep(poll_s)
|
||||
elapsed += poll_s
|
||||
return ""
|
||||
|
||||
async def _click_first(self, candidates: list[str], timeout: int = 8000) -> bool:
|
||||
if not self.page:
|
||||
return False
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
locator = self.page.locator(candidate).first
|
||||
try:
|
||||
if await locator.count() > 0:
|
||||
await locator.wait_for(state="visible", timeout=timeout)
|
||||
await locator.click()
|
||||
await self.human_delay(0.3, 0.8)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
async def _fill_first(self, candidates: list[str], value: str, timeout: int = 8000) -> bool:
|
||||
if not self.page:
|
||||
return False
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
locator = self.page.locator(candidate).first
|
||||
try:
|
||||
if await locator.count() > 0:
|
||||
await locator.wait_for(state="visible", timeout=timeout)
|
||||
await locator.fill(value)
|
||||
await self.human_delay(0.2, 0.5)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Name Search & Reservation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search BC Registry for name availability.
|
||||
|
||||
Uses bcregistrynames.gov.bc.ca Name Request portal.
|
||||
Stub — selectors need portal inspection.
|
||||
"""
|
||||
self.log.info("Searching BC Registry for name: %s", name)
|
||||
selectors = CONFIG["selectors"]
|
||||
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_request_portal"]["url"])
|
||||
await self.human_delay(2.0, 4.0)
|
||||
await self.screenshot("name_search_start")
|
||||
|
||||
# --- STUB: fill in once selectors are captured ---
|
||||
# await self.type_slowly(selectors["name_search_input"], name)
|
||||
# await self.safe_click(selectors["name_search_submit"])
|
||||
# await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
# await self.human_delay(1.5, 3.0)
|
||||
# await self.screenshot("name_search_result")
|
||||
#
|
||||
# available_el = await page.query_selector(selectors["name_result_available"])
|
||||
# unavailable_el = await page.query_selector(selectors["name_result_unavailable"])
|
||||
#
|
||||
# available = available_el is not None and unavailable_el is None
|
||||
|
||||
self.log.warning("BC name search selectors not configured — returning stub result")
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
exact_match=False,
|
||||
similar_names=[],
|
||||
state_code="BC",
|
||||
searched_name=name,
|
||||
raw_response="STUB: selectors not yet configured",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
self.log.error("BC name search failed: %s", exc)
|
||||
await self.screenshot("name_search_error")
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code="BC",
|
||||
searched_name=name,
|
||||
raw_response=str(exc),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def reserve_name(self, name: str) -> dict:
|
||||
"""Submit a Name Request on bcregistrynames.gov.bc.ca.
|
||||
|
||||
Name reservations in BC are valid for 56 days and cost C$30.
|
||||
Numbered companies skip this step entirely.
|
||||
|
||||
Returns:
|
||||
dict with keys: success, nr_number (Name Request number), message
|
||||
"""
|
||||
self.log.info("Reserving name in BC: %s", name)
|
||||
selectors = CONFIG["selectors"]
|
||||
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_request_portal"]["url"])
|
||||
await self.human_delay(2.0, 4.0)
|
||||
|
||||
# --- STUB: fill in once selectors are captured ---
|
||||
# Step 1: Enter name
|
||||
# await self.type_slowly(selectors["name_search_input"], name)
|
||||
# await self.safe_click(selectors["name_search_submit"])
|
||||
# await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
# await self.human_delay(1.5, 3.0)
|
||||
#
|
||||
# Step 2: Click reserve
|
||||
# await self.safe_click(selectors["name_reserve_btn"])
|
||||
# await self.human_delay(1.0, 2.0)
|
||||
#
|
||||
# Step 3: Pay C$30 via Relay card
|
||||
# ... payment selectors ...
|
||||
#
|
||||
# Step 4: Capture NR number from confirmation page
|
||||
|
||||
self.log.warning("BC name reservation selectors not configured — returning stub")
|
||||
await self.screenshot("name_reserve_stub")
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"nr_number": "",
|
||||
"message": "STUB: selectors not yet configured",
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
self.log.error("BC name reservation failed: %s", exc)
|
||||
await self.screenshot("name_reserve_error")
|
||||
return {
|
||||
"success": False,
|
||||
"nr_number": "",
|
||||
"message": str(exc),
|
||||
}
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Incorporation Filing
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def file_incorporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File BC incorporation via Corporate Online.
|
||||
|
||||
Full flow:
|
||||
1. Login to Corporate Online
|
||||
2. Start new incorporation
|
||||
3. Enter company name (or use numbered company)
|
||||
4. Enter registered office address (Anytime Mailbox)
|
||||
5. Enter records office (same as registered)
|
||||
6. Enter director(s)
|
||||
7. Enter share structure
|
||||
8. Upload/confirm articles
|
||||
9. Pay C$350 via Relay virtual debit card
|
||||
10. Capture BC incorporation number from confirmation
|
||||
|
||||
Stub — selectors need portal inspection.
|
||||
"""
|
||||
self.log.info("Filing BC incorporation for: %s", order.entity_name)
|
||||
selectors = CONFIG["selectors"]
|
||||
|
||||
missing_steps = self._missing_colin_step_map()
|
||||
if missing_steps:
|
||||
detail = "; ".join(
|
||||
f"step {step}: {', '.join(keys)}" for step, keys in sorted(missing_steps.items())
|
||||
)
|
||||
self.log.warning("BC incorporation blocked — unverified COLIN selectors: %s", detail)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.PENDING,
|
||||
state_code="BC",
|
||||
entity_name=order.entity_name,
|
||||
filing_number="",
|
||||
confirmation_number="",
|
||||
error_message=f"COLIN selector verification required ({detail})",
|
||||
)
|
||||
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
|
||||
# --- Step 1: Login ---
|
||||
await page.goto(CONFIG["filing_portal"]["login_url"])
|
||||
await self.human_delay(2.0, 4.0)
|
||||
await self.screenshot("inc_login_page")
|
||||
|
||||
# await self.type_slowly(selectors["login_username"], os.getenv("BC_REGISTRY_USERNAME", ""))
|
||||
# await self.type_slowly(selectors["login_password"], os.getenv("BC_REGISTRY_PASSWORD", ""))
|
||||
# await self.safe_click(selectors["login_submit"])
|
||||
# await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
# await self.human_delay(2.0, 4.0)
|
||||
# await self.screenshot("inc_logged_in")
|
||||
|
||||
# --- Step 2: Start new incorporation ---
|
||||
# Navigate to incorporation form
|
||||
# await page.goto(CONFIG["filing_portal"]["url"] + "/incorporation/new")
|
||||
# await self.human_delay(1.5, 3.0)
|
||||
|
||||
# --- Step 3: Company name ---
|
||||
# await self.type_slowly(selectors["inc_company_name"], order.entity_name)
|
||||
# await self.human_delay(0.5, 1.0)
|
||||
|
||||
# --- Step 4: Registered office (Anytime Mailbox) ---
|
||||
office = CONFIG["registered_office"]
|
||||
# await self.type_slowly(selectors["inc_registered_office_street"], office["street"])
|
||||
# await self.type_slowly(selectors["inc_registered_office_city"], office["city"])
|
||||
# await self.type_slowly(selectors["inc_registered_office_province"], office["province"])
|
||||
# await self.type_slowly(selectors["inc_registered_office_postal"], office["postal_code"])
|
||||
|
||||
# --- Step 5: Records office = same as registered ---
|
||||
# await self.safe_click(selectors["inc_records_office_same"])
|
||||
|
||||
# --- Step 6: Director(s) ---
|
||||
# for member in order.members:
|
||||
# await self.type_slowly(selectors["inc_director_name"], member.name)
|
||||
# await self.type_slowly(selectors["inc_director_address"],
|
||||
# f"{member.address}, {member.city}, {member.state} {member.zip_code}")
|
||||
# await self.human_delay(0.5, 1.0)
|
||||
|
||||
# --- Step 7: Share structure ---
|
||||
# await self.type_slowly(selectors["inc_share_structure"], str(order.shares_authorized))
|
||||
|
||||
# --- Step 8: Articles ---
|
||||
# Default articles for BC are standard — just confirm
|
||||
# await self.safe_click(selectors["inc_articles"])
|
||||
|
||||
# --- Step 9: Payment (C$350 via Relay card) ---
|
||||
# payment_selectors = {
|
||||
# "card_number_field": selectors["pay_card_number"],
|
||||
# "card_exp_field": selectors["pay_card_exp"],
|
||||
# "card_cvv_field": selectors["pay_card_cvv"],
|
||||
# "card_name_field": selectors["pay_card_name"],
|
||||
# "submit_payment_btn": selectors["pay_submit"],
|
||||
# }
|
||||
# await self.enter_payment(order, payment_selectors)
|
||||
|
||||
# --- Step 10: Capture confirmation ---
|
||||
# await self.screenshot("inc_confirmation")
|
||||
# bc_number = await page.text_content(".confirmation-number") # placeholder selector
|
||||
|
||||
self.log.warning("BC incorporation selectors not configured — returning stub")
|
||||
await self.screenshot("inc_stub")
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.PENDING,
|
||||
state_code="BC",
|
||||
entity_name=order.entity_name,
|
||||
filing_number="",
|
||||
confirmation_number="",
|
||||
error_message="STUB: selectors not yet configured",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
self.log.error("BC incorporation failed: %s", exc)
|
||||
await self.screenshot("inc_error")
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code="BC",
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(exc),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# file_llc / file_corporation — required by StatePortal ABC
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""LLCs do not exist under Canadian law."""
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code="BC",
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLCs are not available in Canada. Use file_incorporation() for a BC corporation.",
|
||||
)
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File a BC corporation — delegates to file_incorporation()."""
|
||||
return await self.file_incorporation(order)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Anytime Mailbox Setup
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def scrape_available_units(self, location_url: str) -> list[str]:
|
||||
"""Scrape available mailbox unit numbers from an Anytime Mailbox location page.
|
||||
|
||||
Navigates to the location, clicks through to the mailbox number selection step,
|
||||
and extracts all available unit numbers from the dropdown.
|
||||
|
||||
Returns a list of available unit number strings (e.g. ["101", "205", "B438"]).
|
||||
"""
|
||||
self.log.info("Scraping available units from: %s", location_url)
|
||||
units = []
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(location_url, wait_until="networkidle", timeout=30000)
|
||||
await self.human_delay(2.0, 3.0)
|
||||
|
||||
# Click SELECT on the first/cheapest plan to enter signup flow
|
||||
await self._click_first([
|
||||
'button:has-text("Select")',
|
||||
'a:has-text("Select")',
|
||||
'button:has-text("SELECT")',
|
||||
])
|
||||
await self.human_delay(2.0, 3.0)
|
||||
|
||||
# Click yearly plan period if available
|
||||
await self._click_first([
|
||||
'button:has-text("Yearly")',
|
||||
'label:has-text("Yearly")',
|
||||
'button:has-text("Annual")',
|
||||
])
|
||||
await self.human_delay(1.0, 2.0)
|
||||
|
||||
# Click continue/select to get to mailbox number step
|
||||
await self._click_first([
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Select")',
|
||||
'button:has-text("Next")',
|
||||
])
|
||||
await self.human_delay(2.0, 3.0)
|
||||
|
||||
# Extract unit numbers from dropdown/select element
|
||||
# AMB uses a dropdown or list for available mailbox numbers
|
||||
unit_options = await page.evaluate("""() => {
|
||||
// Try select dropdowns
|
||||
const selects = document.querySelectorAll('select');
|
||||
for (const sel of selects) {
|
||||
const opts = [...sel.options].filter(o => o.value && o.value !== '');
|
||||
if (opts.length > 1) {
|
||||
return opts.map(o => o.value || o.textContent.trim());
|
||||
}
|
||||
}
|
||||
// Try radio buttons or clickable list items
|
||||
const radios = document.querySelectorAll('input[type="radio"][name*="mailbox"], input[type="radio"][name*="unit"]');
|
||||
if (radios.length > 0) {
|
||||
return [...radios].map(r => r.value || r.parentElement?.textContent?.trim() || '');
|
||||
}
|
||||
// Try list items that look like unit numbers
|
||||
const items = document.querySelectorAll('[class*="mailbox"], [class*="unit"], [data-unit]');
|
||||
if (items.length > 0) {
|
||||
return [...items].map(i => i.textContent?.trim() || i.getAttribute('data-unit') || '').filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}""")
|
||||
|
||||
units = [str(u).strip() for u in (unit_options or []) if str(u).strip()]
|
||||
self.log.info("Found %d available units at %s", len(units), location_url)
|
||||
await self.screenshot("mailbox_units_available")
|
||||
|
||||
except Exception as e:
|
||||
self.log.error("Failed to scrape units from %s: %s", location_url, e)
|
||||
finally:
|
||||
try:
|
||||
await self.stop_browser()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return units
|
||||
|
||||
async def signup_with_unit(self, order: FormationOrder, unit_number: str, location_url: str) -> dict:
|
||||
"""Sign up for Anytime Mailbox with a specific pre-selected unit number.
|
||||
|
||||
Similar to setup_mailbox() but uses a specific location URL and unit number
|
||||
that the client selected in the portal, instead of auto-picking.
|
||||
|
||||
Returns dict with success, unit_number, mailbox_id, account_email.
|
||||
"""
|
||||
self.log.info("Signing up at %s with unit %s for: %s", location_url, unit_number, order.entity_name)
|
||||
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(location_url, wait_until="networkidle", timeout=30000)
|
||||
await self.human_delay(2.0, 3.0)
|
||||
|
||||
# Click SELECT on the cheapest plan
|
||||
await self._click_first([
|
||||
'button:has-text("Select")',
|
||||
'a:has-text("Select")',
|
||||
])
|
||||
await self.human_delay(2.0, 3.0)
|
||||
|
||||
# Select yearly
|
||||
await self._click_first([
|
||||
'button:has-text("Yearly")',
|
||||
'label:has-text("Yearly")',
|
||||
])
|
||||
await self.human_delay(1.0, 2.0)
|
||||
|
||||
await self._click_first([
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Select")',
|
||||
])
|
||||
await self.human_delay(2.0, 3.0)
|
||||
|
||||
# Select the specific unit number from dropdown
|
||||
selects = await page.query_selector_all("select")
|
||||
unit_selected = False
|
||||
for sel in selects:
|
||||
opts = await sel.evaluate("el => [...el.options].map(o => o.value)")
|
||||
if unit_number in opts:
|
||||
await sel.select_option(unit_number)
|
||||
unit_selected = True
|
||||
break
|
||||
if not unit_selected:
|
||||
# Try clicking the unit in a list/radio
|
||||
await self._click_first([
|
||||
f'input[value="{unit_number}"]',
|
||||
f'label:has-text("{unit_number}")',
|
||||
f'[data-unit="{unit_number}"]',
|
||||
])
|
||||
await self.human_delay(1.0, 2.0)
|
||||
|
||||
# Now proceed with the rest of the signup flow (same as setup_mailbox)
|
||||
member_name = order.members[0].name if order.members else order.regulatory_contact_name or "Client Name"
|
||||
name_parts = member_name.split(" ", 1)
|
||||
first_name = name_parts[0]
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else "Client"
|
||||
|
||||
signup_email = (
|
||||
os.getenv("ANYTIME_MAILBOX_SIGNUP_EMAIL", "").strip()
|
||||
or f'mailbox+{order.order_id.lower()}@performancewest.net'
|
||||
)
|
||||
signup_phone = order.regulatory_contact_phone or os.getenv("ANYTIME_MAILBOX_SIGNUP_PHONE", "+16025550123")
|
||||
signup_password = os.getenv("ANYTIME_MAILBOX_DEFAULT_PASSWORD", "").strip() or f"Pw!{secrets.token_hex(8)}"
|
||||
|
||||
await self._fill_first(['input[name*="first" i]'], first_name)
|
||||
await self._fill_first(['input[name*="last" i]'], last_name)
|
||||
await self._fill_first(['input[name*="business" i]'], order.entity_name)
|
||||
|
||||
await self._click_first(['button:has-text("Continue")', 'button:has-text("Next")'])
|
||||
await self.human_delay(1.5, 2.5)
|
||||
|
||||
# Contact details
|
||||
full_street = order.principal_address or order.mailing_address or "5307 Victoria Dr"
|
||||
city = order.principal_city or order.mailing_city or "Vancouver"
|
||||
province = order.principal_state or order.mailing_state or "BC"
|
||||
postal = order.principal_zip or order.mailing_zip or "V5P 3V6"
|
||||
|
||||
await self._fill_first(['input[name*="address" i]'], full_street)
|
||||
await self._fill_first(['input[name*="city" i]'], city)
|
||||
await self._fill_first(['input[name*="state" i]', 'input[name*="province" i]'], province)
|
||||
await self._fill_first(['input[name*="zip" i]', 'input[name*="postal" i]'], postal)
|
||||
await self._fill_first(['input[type="email"]'], signup_email)
|
||||
await self._fill_first(['input[type="tel"]'], signup_phone)
|
||||
await self._fill_first(['input[type="password"]'], signup_password)
|
||||
|
||||
await self._click_first(['button:has-text("Continue")', 'button:has-text("Next")'])
|
||||
await self.human_delay(1.5, 2.5)
|
||||
|
||||
# OTP verification
|
||||
otp_code = os.getenv("ANYTIME_MAILBOX_OTP_CODE", "").strip()
|
||||
if not otp_code:
|
||||
otp_code = await self._wait_for_anytime_otp(signup_email)
|
||||
if otp_code:
|
||||
await self._fill_first([
|
||||
'input[name*="verification" i]',
|
||||
'input[name*="otp" i]',
|
||||
'input[inputmode="numeric"]',
|
||||
], otp_code)
|
||||
await self._click_first(['button:has-text("Verify")', 'button:has-text("Continue")'])
|
||||
else:
|
||||
await self.screenshot("mailbox_signup_waiting_otp")
|
||||
return {
|
||||
"success": False,
|
||||
"unit_number": unit_number,
|
||||
"mailbox_id": "",
|
||||
"message": "OTP required: set ANYTIME_MAILBOX_OTP_CODE and retry",
|
||||
}
|
||||
|
||||
# Checkout
|
||||
await self._click_first([
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Checkout")',
|
||||
'button:has-text("Submit")',
|
||||
])
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
await self.human_delay(1.5, 3.0)
|
||||
await self.screenshot("mailbox_signup_after_checkout")
|
||||
|
||||
page_text = (await page.content()) or ""
|
||||
id_match = re.search(r"(?:Mailbox\s*ID|ID)\s*[:#]?\s*([A-Za-z0-9\-]{4,})", page_text, re.IGNORECASE)
|
||||
mailbox_id = id_match.group(1) if id_match else ""
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"unit_number": unit_number,
|
||||
"mailbox_id": mailbox_id,
|
||||
"message": "Mailbox signup completed",
|
||||
"account_email": signup_email,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.log.error("Mailbox signup failed: %s", e)
|
||||
await self.screenshot("mailbox_signup_error")
|
||||
return {
|
||||
"success": False,
|
||||
"unit_number": unit_number,
|
||||
"mailbox_id": "",
|
||||
"message": f"Signup failed: {e}",
|
||||
}
|
||||
|
||||
async def setup_mailbox(self, order: FormationOrder) -> dict:
|
||||
"""Register an Anytime Mailbox account at 329 Howe St, Vancouver.
|
||||
|
||||
Flow:
|
||||
1. Navigate to anytimemailbox.com
|
||||
2. Select the Vancouver - Howe St location
|
||||
3. Choose the Silver plan (C$164.99/yr)
|
||||
4. Complete checkout with client details
|
||||
5. Capture mailbox unit number for the registered office address
|
||||
|
||||
Returns:
|
||||
dict with keys: success, unit_number, mailbox_id, message
|
||||
"""
|
||||
self.log.info("Setting up Anytime Mailbox for: %s", order.entity_name)
|
||||
selectors = CONFIG["selectors"]
|
||||
office_id = CONFIG.get("registered_office_default", "victoria-dr")
|
||||
office = CONFIG.get("registered_office_locations", {}).get(office_id, CONFIG["registered_office"])
|
||||
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
provider_url = office.get("provider_url") or CONFIG["registered_office"]["provider_url"]
|
||||
await page.goto(provider_url)
|
||||
await self.human_delay(2.0, 4.0)
|
||||
await self.screenshot("mailbox_start")
|
||||
|
||||
# Step 1: location lookup and select plan
|
||||
await self._fill_first(
|
||||
[selectors.get("amb_location_search", ""), 'input[placeholder*="city" i]', 'input[type="search"]'],
|
||||
f'{office.get("city", "Vancouver")} {office.get("province", "BC")}',
|
||||
)
|
||||
await self._click_first(
|
||||
[
|
||||
selectors.get("amb_location_select", ""),
|
||||
f'text={office.get("street", "")}',
|
||||
'button:has-text("Select")',
|
||||
]
|
||||
)
|
||||
|
||||
# Step 2: pick plan (yearly preferred)
|
||||
await self._click_first(
|
||||
[
|
||||
selectors.get("amb_plan_period_yearly", ""),
|
||||
'button:has-text("Yearly")',
|
||||
'label:has-text("Yearly")',
|
||||
]
|
||||
)
|
||||
await self._click_first(
|
||||
[
|
||||
selectors.get("amb_plan_select", ""),
|
||||
f'text={office.get("plan", "Basic")}',
|
||||
'button:has-text("Select")',
|
||||
'button:has-text("Continue")',
|
||||
]
|
||||
)
|
||||
|
||||
# Step 3: mailbox number + identity details
|
||||
await self._click_first([
|
||||
selectors.get("amb_mailbox_number_first", ""),
|
||||
'button:has-text("Choose")',
|
||||
'button:has-text("Select")',
|
||||
])
|
||||
|
||||
member_name = order.members[0].name if order.members else order.regulatory_contact_name or "Client Name"
|
||||
name_parts = member_name.split(" ", 1)
|
||||
first_name = name_parts[0]
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else "Client"
|
||||
|
||||
signup_email = (
|
||||
os.getenv("ANYTIME_MAILBOX_SIGNUP_EMAIL", "").strip()
|
||||
or f'mailbox+{order.order_id.lower()}@performancewest.net'
|
||||
)
|
||||
signup_phone = order.regulatory_contact_phone or os.getenv("ANYTIME_MAILBOX_SIGNUP_PHONE", "+16025550123")
|
||||
signup_password = os.getenv("ANYTIME_MAILBOX_DEFAULT_PASSWORD", "").strip() or f"Pw!{secrets.token_hex(8)}"
|
||||
|
||||
await self._fill_first([selectors.get("amb_first_name", ""), 'input[name*="first" i]'], first_name)
|
||||
await self._fill_first([selectors.get("amb_last_name", ""), 'input[name*="last" i]'], last_name)
|
||||
await self._fill_first([selectors.get("amb_business_name", ""), 'input[name*="business" i]'], order.entity_name)
|
||||
|
||||
await self._click_first([
|
||||
selectors.get("amb_continue", ""),
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Next")',
|
||||
])
|
||||
|
||||
# Step 4: contact details + account credentials
|
||||
full_street = order.principal_address or order.mailing_address or "5307 Victoria Dr"
|
||||
city = order.principal_city or order.mailing_city or office.get("city", "Vancouver")
|
||||
province = order.principal_state or order.mailing_state or office.get("province", "BC")
|
||||
postal = order.principal_zip or order.mailing_zip or office.get("postal_code", "V5P 3V6")
|
||||
|
||||
await self._fill_first([selectors.get("amb_home_address", ""), 'input[name*="address" i]'], full_street)
|
||||
await self._fill_first([selectors.get("amb_home_city", ""), 'input[name*="city" i]'], city)
|
||||
await self._fill_first([selectors.get("amb_home_state", ""), 'input[name*="state" i]', 'input[name*="province" i]'], province)
|
||||
await self._fill_first([selectors.get("amb_home_postal", ""), 'input[name*="zip" i]', 'input[name*="postal" i]'], postal)
|
||||
await self._fill_first([selectors.get("amb_email", ""), 'input[type="email"]'], signup_email)
|
||||
await self._fill_first([selectors.get("amb_phone", ""), 'input[type="tel"]'], signup_phone)
|
||||
await self._fill_first([selectors.get("amb_password", ""), 'input[type="password"]'], signup_password)
|
||||
|
||||
await self._click_first([
|
||||
selectors.get("amb_continue", ""),
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Next")',
|
||||
])
|
||||
|
||||
# Step 5: OTP verification (required).
|
||||
otp_code = os.getenv("ANYTIME_MAILBOX_OTP_CODE", "").strip()
|
||||
if not otp_code:
|
||||
otp_code = await self._wait_for_anytime_otp(signup_email)
|
||||
if otp_code:
|
||||
await self._fill_first(
|
||||
[
|
||||
selectors.get("amb_otp", ""),
|
||||
'input[name*="verification" i]',
|
||||
'input[name*="otp" i]',
|
||||
'input[inputmode="numeric"]',
|
||||
],
|
||||
otp_code,
|
||||
)
|
||||
await self._click_first([
|
||||
selectors.get("amb_otp_submit", ""),
|
||||
'button:has-text("Verify")',
|
||||
'button:has-text("Continue")',
|
||||
])
|
||||
else:
|
||||
await self.screenshot("mailbox_waiting_otp")
|
||||
return {
|
||||
"success": False,
|
||||
"unit_number": "",
|
||||
"mailbox_id": "",
|
||||
"message": "OTP required: set ANYTIME_MAILBOX_OTP_CODE and retry",
|
||||
}
|
||||
|
||||
# Step 6: review + checkout
|
||||
await self._click_first([
|
||||
selectors.get("amb_checkout_submit", ""),
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Checkout")',
|
||||
'button:has-text("Submit")',
|
||||
])
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
await self.human_delay(1.5, 3.0)
|
||||
await self.screenshot("mailbox_after_checkout")
|
||||
|
||||
page_text = (await page.content()) or ""
|
||||
unit_match = re.search(r"(?:Suite|Unit|Mailbox|#)\s*([A-Za-z0-9\-]+)", page_text, re.IGNORECASE)
|
||||
id_match = re.search(r"(?:Mailbox\s*ID|ID)\s*[:#]?\s*([A-Za-z0-9\-]{4,})", page_text, re.IGNORECASE)
|
||||
mailbox_unit = unit_match.group(1) if unit_match else ""
|
||||
mailbox_id = id_match.group(1) if id_match else ""
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"unit_number": mailbox_unit,
|
||||
"mailbox_id": mailbox_id,
|
||||
"message": "Anytime Mailbox signup submitted",
|
||||
"account_email": signup_email,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
self.log.error("Anytime Mailbox setup failed: %s", exc)
|
||||
await self.screenshot("mailbox_error")
|
||||
return {
|
||||
"success": False,
|
||||
"unit_number": "",
|
||||
"mailbox_id": "",
|
||||
"message": str(exc),
|
||||
}
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CRTC Notification Letter
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def generate_crtc_letter(self, order: FormationOrder) -> Optional[str]:
|
||||
"""Generate a CRTC notification letter from the DOCX template.
|
||||
|
||||
Fills the template with:
|
||||
- Corporation name, BC incorporation number, date
|
||||
- Registered office address (Anytime Mailbox)
|
||||
- Director name(s)
|
||||
- CRTC Secretary General address
|
||||
|
||||
Returns:
|
||||
Path to the generated PDF, or None on failure.
|
||||
"""
|
||||
self.log.info("Generating CRTC letter for: %s", order.entity_name)
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
template_path = Path(CRTC_TEMPLATE)
|
||||
if not template_path.exists():
|
||||
self.log.error("CRTC letter template not found: %s", template_path)
|
||||
return None
|
||||
|
||||
doc = Document(str(template_path))
|
||||
|
||||
office = CONFIG["registered_office"]
|
||||
crtc = CONFIG["crtc"]
|
||||
|
||||
# Build director list
|
||||
directors = ", ".join(m.name for m in order.members) if order.members else "N/A"
|
||||
|
||||
# Variable replacements
|
||||
variables = {
|
||||
"{{date}}": datetime.utcnow().strftime("%B %d, %Y"),
|
||||
"{{entity_name}}": order.entity_name,
|
||||
"{{bc_number}}": order.state_filing_number or "PENDING",
|
||||
"{{incorporation_date}}": order.filed_at or datetime.utcnow().strftime("%B %d, %Y"),
|
||||
"{{registered_office}}": (
|
||||
f"{office['street']}, {office['city']}, "
|
||||
f"{office['province']} {office['postal_code']}"
|
||||
),
|
||||
"{{directors}}": directors,
|
||||
"{{crtc_address}}": (
|
||||
f"{crtc['secretary_general']}\n"
|
||||
f"{crtc['address']}\n"
|
||||
f"{crtc['city']}, {crtc['province']} {crtc['postal_code']}"
|
||||
),
|
||||
}
|
||||
|
||||
# Replace placeholders in paragraphs
|
||||
for paragraph in doc.paragraphs:
|
||||
for key, value in variables.items():
|
||||
if key in paragraph.text:
|
||||
for run in paragraph.runs:
|
||||
if key in run.text:
|
||||
run.text = run.text.replace(key, value)
|
||||
|
||||
# Replace placeholders in tables
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
for key, value in variables.items():
|
||||
if key in paragraph.text:
|
||||
for run in paragraph.runs:
|
||||
if key in run.text:
|
||||
run.text = run.text.replace(key, value)
|
||||
|
||||
# Save DOCX
|
||||
work_dir = tempfile.mkdtemp(prefix="pw_crtc_")
|
||||
docx_path = os.path.join(work_dir, f"crtc_letter_{order.order_id}.docx")
|
||||
doc.save(docx_path)
|
||||
self.log.info("CRTC letter DOCX saved: %s", docx_path)
|
||||
|
||||
# Convert to PDF via LibreOffice
|
||||
result = subprocess.run(
|
||||
[
|
||||
"libreoffice", "--headless",
|
||||
"--convert-to", "pdf",
|
||||
"--outdir", work_dir,
|
||||
docx_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
self.log.error("LibreOffice conversion failed: %s", result.stderr)
|
||||
return None
|
||||
|
||||
pdf_path = os.path.join(work_dir, f"crtc_letter_{order.order_id}.pdf")
|
||||
if not Path(pdf_path).exists():
|
||||
self.log.error("CRTC letter PDF not generated at: %s", pdf_path)
|
||||
return None
|
||||
|
||||
self.log.info("CRTC letter PDF generated: %s", pdf_path)
|
||||
return pdf_path
|
||||
|
||||
except Exception as exc:
|
||||
self.log.error("CRTC letter generation failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# Module-level convenience instance
|
||||
adapter = BCPortal()
|
||||
615
scripts/formation/states/bc/config.py
Normal file
615
scripts/formation/states/bc/config.py
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
"""
|
||||
Configuration for British Columbia, Canada — BC Business Corporations Act.
|
||||
|
||||
BC uses a provincial incorporation system (not federal), governed by the
|
||||
BC Business Corporations Act (SBC 2002, c. 57). Entities formed here are
|
||||
BC corporations — LLCs do not exist under Canadian law.
|
||||
|
||||
Portal stack:
|
||||
- Corporate Online (corporateonline.gov.bc.ca) — filing & annual reports
|
||||
NOTE: No login required for new incorporations — the wizard is anonymous
|
||||
and payment is taken by credit card at the end. Do NOT attempt username/
|
||||
password auth — the login page is IDIR-only (government employees).
|
||||
- BC Registry Name Request (bcregistrynames.gov.bc.ca) — name reservation
|
||||
- Anytime Mailbox (anytimemailbox.com) — virtual mailbox for registered office
|
||||
- CRTC — Canadian Radio-television and Telecommunications Commission
|
||||
(requires notification letter for telecom carriers)
|
||||
|
||||
Currency: all fees in CAD (C$).
|
||||
|
||||
COLIN wizard steps (in order):
|
||||
1. Initial Information — company name / effective date
|
||||
2. Incorporator Info — incorporator name + address
|
||||
3. Completing Party — person completing the filing
|
||||
4. Translated Name — (skip — not applicable)
|
||||
5. Director Info — director name(s) + address(es)
|
||||
6. Office Addresses — registered office + records office
|
||||
7. Share Structure — share classes (Common shares, no par value)
|
||||
8. Notification — email for receipt
|
||||
9. Company Information — confirm name + type
|
||||
10. Confirm Company Info — review everything
|
||||
11. Ready to Pay — credit card entry
|
||||
12. Your Receipt — BC incorporation number
|
||||
"""
|
||||
|
||||
CONFIG = {
|
||||
"jurisdiction": "British Columbia",
|
||||
"country": "Canada",
|
||||
"abbreviation": "BC",
|
||||
"entity_types": ["corporation"], # No LLCs in Canada
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Portal schedule — BC Corporate Online hours + BC holidays
|
||||
# Mon–Sat 6 AM – 10 PM, Sun 1 PM – 10 PM Pacific
|
||||
# ------------------------------------------------------------------ #
|
||||
"portal_schedule": {
|
||||
"timezone": "America/Vancouver",
|
||||
"jurisdiction": "BC",
|
||||
"closed_holidays": True,
|
||||
"hours": {
|
||||
"mon": [6, 22],
|
||||
"tue": [6, 22],
|
||||
"wed": [6, 22],
|
||||
"thu": [6, 22],
|
||||
"fri": [6, 22],
|
||||
"sat": [6, 22],
|
||||
"sun": [13, 22],
|
||||
},
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BC Registry — Corporate Online
|
||||
# No authentication required — anonymous public filing portal.
|
||||
# ------------------------------------------------------------------ #
|
||||
"agency": "BC Registry Services",
|
||||
"agency_url": "https://www.bcregistry.gov.bc.ca",
|
||||
"filing_portal": {
|
||||
"name": "Corporate Online",
|
||||
"url": "https://www.corporateonline.gov.bc.ca",
|
||||
# Direct URL to start a new Incorporation Application (anonymous)
|
||||
"icorp_start_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/accesstransaction/menu.do?action=startFiling&filingTypeCode=ICORP&from=main",
|
||||
"icorp_overview_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/accesstransaction/menu.do?action=overview&filingTypeCode=ICORP&from=main",
|
||||
# Annual report
|
||||
"annual_report_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/accesstransaction/menu.do?action=startFiling&filingTypeCode=ANNBC&from=main",
|
||||
# Legacy — kept for compat; IDIR-only now (not used for automation)
|
||||
"login_url": "https://www.corporateonline.gov.bc.ca/corporateonline/colin/login/login.do",
|
||||
},
|
||||
"name_request_portal": {
|
||||
"name": "BC Registry Name Request",
|
||||
"url": "https://www.bcregistrynames.gov.bc.ca",
|
||||
"search_url": "https://www.bcregistrynames.gov.bc.ca/nrSearch/name-search",
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Registered & Records Office — Anytime Mailbox (BC locations)
|
||||
# ------------------------------------------------------------------ #
|
||||
"registered_office_default": "victoria-dr",
|
||||
"registered_office_locations": {
|
||||
"victoria-dr": {
|
||||
"id": "victoria-dr",
|
||||
"label": "Vancouver - Victoria Dr (Best Value)",
|
||||
"street": "5307 Victoria Dr",
|
||||
"suite_prefix": "Suite",
|
||||
"city": "Vancouver",
|
||||
"province": "BC",
|
||||
"postal_code": "V5P 3V6",
|
||||
"country": "Canada",
|
||||
"plan": "Basic",
|
||||
"plan_cost_cad": 99.00,
|
||||
"plan_period": "yearly",
|
||||
"default": True,
|
||||
},
|
||||
"howe-st": {
|
||||
"id": "howe-st",
|
||||
"label": "Vancouver - Howe St (Downtown)",
|
||||
"street": "329 Howe St",
|
||||
"suite_prefix": "Unit",
|
||||
"city": "Vancouver",
|
||||
"province": "BC",
|
||||
"postal_code": "V6C 3N2",
|
||||
"country": "Canada",
|
||||
"plan": "Silver",
|
||||
"plan_cost_cad": 164.99,
|
||||
"plan_period": "yearly",
|
||||
"default": False,
|
||||
},
|
||||
"broadway": {
|
||||
"id": "broadway",
|
||||
"label": "Vancouver - Broadway",
|
||||
"street": "1275 W Broadway",
|
||||
"suite_prefix": "Suite",
|
||||
"city": "Vancouver",
|
||||
"province": "BC",
|
||||
"postal_code": "V6H 1G2",
|
||||
"country": "Canada",
|
||||
"plan": "Silver",
|
||||
"plan_cost_cad": 149.99,
|
||||
"plan_period": "yearly",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
# Legacy field — kept for backward compatibility
|
||||
"registered_office": {
|
||||
"provider": "Anytime Mailbox",
|
||||
"provider_url": "https://www.anytimemailbox.com",
|
||||
"location": "Vancouver - Victoria Dr",
|
||||
"street": "5307 Victoria Dr",
|
||||
"city": "Vancouver",
|
||||
"province": "BC",
|
||||
"postal_code": "V5P 3V6",
|
||||
"country": "Canada",
|
||||
"plan": "Basic",
|
||||
"plan_cost_cad": 99.00,
|
||||
"plan_period": "yearly",
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CRTC
|
||||
# ------------------------------------------------------------------ #
|
||||
"crtc": {
|
||||
"name": "Canadian Radio-television and Telecommunications Commission",
|
||||
"short_name": "CRTC",
|
||||
"secretary_general": "Secretary General, CRTC",
|
||||
"address": "1 Promenade du Portage",
|
||||
"city": "Gatineau",
|
||||
"province": "QC",
|
||||
"postal_code": "J8X 4B1",
|
||||
"country": "Canada",
|
||||
"website": "https://crtc.gc.ca",
|
||||
"notification_required": True,
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BITS (Basic International Telecommunications Services)
|
||||
# ------------------------------------------------------------------ #
|
||||
"bits": {
|
||||
"name": "BITS Registration",
|
||||
"description": (
|
||||
"All Canadian telecom carriers must register with the CRTC "
|
||||
"under the Basic International Telecommunications Services (BITS) regime. "
|
||||
"Registration is filed via letter to the CRTC Secretary General."
|
||||
),
|
||||
"filing_method": "letter", # submitted with the CRTC notification letter
|
||||
"annual_fee_cad": 0.00, # no fee for initial BITS notification
|
||||
"renewal_required": False, # initial registration is a one-time notification
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CCTS (Commission for Complaints for Telecom-television Services)
|
||||
# ------------------------------------------------------------------ #
|
||||
"ccts": {
|
||||
"name": "Commission for Complaints for Telecom-television Services",
|
||||
"short_name": "CCTS",
|
||||
"website": "https://www.ccts-cprst.ca",
|
||||
"membership_url": "https://www.ccts-cprst.ca/for-service-providers/become-a-member/",
|
||||
"description": (
|
||||
"All Canadian telecom service providers must participate in the CCTS, "
|
||||
"the national and independent organization dedicated to resolving "
|
||||
"customer complaints about telecom and TV services. "
|
||||
"Membership application is submitted online."
|
||||
),
|
||||
"filing_method": "online_form",
|
||||
"annual_fee_cad": 0.00, # no fee for small carriers in first year
|
||||
"renewal_required": True,
|
||||
"renewal_period": "Yearly",
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GCKey — Government of Canada authentication credential
|
||||
# Used to access My CRTC Account for electronic filings.
|
||||
# Each carrier gets its own GCKey. Signup is a 5-step Spring Web Flow
|
||||
# wizard with hCaptcha invisible on the username step.
|
||||
# ------------------------------------------------------------------ #
|
||||
"gckey": {
|
||||
"name": "GCKey",
|
||||
"description": "Government of Canada authentication credential for online services",
|
||||
"homepage": "https://www.gckey.gc.ca",
|
||||
"auth_domain": "clegc-gckey.gc.ca",
|
||||
# SAML entry: go through CRTC SmartForms → GACS → GCKey
|
||||
"saml_entry_url": "https://services.crtc.gc.ca/Pro/SmartForms/?_gc_lang=eng",
|
||||
"signup_path": "/j/eng/rg", # append ?ReqID=... from SAML flow
|
||||
# Signup wizard — 5 steps (Spring Web Flow)
|
||||
"signup_steps": {
|
||||
# Step 1: Terms and Conditions
|
||||
"terms": {
|
||||
"execution": "e1s1",
|
||||
"accept_btn": "input[name=_eventId_accept]",
|
||||
"decline_btn": "input[name=_eventId_cancel]",
|
||||
},
|
||||
# Step 2: Create Username
|
||||
"username": {
|
||||
"execution": "e1s2",
|
||||
"field": "input[name=uid][id=userID]",
|
||||
"submit_btn": "input[name=_eventId_submit][id=button]",
|
||||
"hcaptcha_sitekey": "99871bd1-7b22-417a-b6cc-7ef645e5147a",
|
||||
},
|
||||
# Step 3: Create Password (selectors to be verified on first live run)
|
||||
"password": {
|
||||
"execution": "e1s3", # inferred — may be e1s3 or later
|
||||
"field": "input[type=password][name=pwd]", # inferred
|
||||
"confirm_field": "input[type=password][name=confirmPwd]", # inferred
|
||||
"submit_btn": "input[name=_eventId_submit]",
|
||||
},
|
||||
# Step 4: Recovery Questions
|
||||
"security_questions": {
|
||||
"execution": "e1s4", # inferred
|
||||
"question_selects": "select", # multiple <select> elements
|
||||
"answer_inputs": "input[type=text]", # answer fields
|
||||
"submit_btn": "input[name=_eventId_submit]",
|
||||
},
|
||||
# Step 5: Recovery Email (to be verified)
|
||||
"email": {
|
||||
"execution": "e1s5", # inferred
|
||||
"field": "input[type=email], input[name=email]", # inferred
|
||||
"submit_btn": "input[name=_eventId_submit]",
|
||||
},
|
||||
},
|
||||
# Login page — used after account creation to verify the credentials work
|
||||
"login_selectors": {
|
||||
"username": "input[name=token1][id=token1]",
|
||||
"password": "input[name=token2][id=token2]",
|
||||
"signin_btn": "button[id=button]",
|
||||
"csrf": "input[name=_csrf]",
|
||||
"hcaptcha_sitekey": "c745648c-d973-4223-99af-8d178dc17a6c",
|
||||
},
|
||||
# Username format: pw-{bc_number} — deterministic per carrier
|
||||
"username_prefix": "pw-",
|
||||
# Password rules (from GCKey help page — to be verified)
|
||||
"password_rules": {
|
||||
"min_length": 8,
|
||||
"max_length": 16,
|
||||
"require_upper": True,
|
||||
"require_lower": True,
|
||||
"require_digit": True,
|
||||
"require_special": True,
|
||||
},
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ATS — CRTC Annual Telecommunications Survey
|
||||
# All registered carriers must file annually via My CRTC Account (GCKey).
|
||||
# ------------------------------------------------------------------ #
|
||||
"ats": {
|
||||
"name": "CRTC Annual Telecommunications Survey",
|
||||
"portal_url": "https://services.crtc.gc.ca/Pro/SmartForms/?_gc_lang=eng",
|
||||
"gckey_url": "https://www.gckey.gc.ca",
|
||||
"my_crtc_url": "http://crtc.gc.ca/eng/forms/form_index.htm",
|
||||
# Activation code: required for first electronic submission.
|
||||
# Obtained by calling CRTC at 1-877-249-2782 or included in
|
||||
# registration confirmation letter (30-60 days after filing).
|
||||
"activation_code_phone": "1-877-249-2782",
|
||||
"forms": {
|
||||
"rep_t1": {
|
||||
"name": "REP-T/T1 — Annual Telecommunications Survey",
|
||||
"description": "Core annual survey for all telecom service providers",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 1,
|
||||
"threshold": "All registered carriers must file — no revenue threshold",
|
||||
"required_for_new_carriers": True,
|
||||
},
|
||||
"rep_u": {
|
||||
"name": "REP-U — Universal Broadband Fund Survey",
|
||||
"description": "Survey for carriers participating in broadband fund",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 31,
|
||||
"threshold": "Carriers with >$10M CAD annual Canadian telecom revenue",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
"form_802a": {
|
||||
"name": "Form 802a — Contribution Survey",
|
||||
"description": "Annual contribution obligation calculation",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 31,
|
||||
"threshold": "Carriers with >$10M CAD annual Canadian telecom revenue",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
"form_802j": {
|
||||
"name": "Form 802j — Contribution Eligibility Survey",
|
||||
"description": "For carriers seeking subsidy eligibility under the national contribution fund",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 31,
|
||||
"threshold": "Only carriers seeking contribution fund subsidy eligibility",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
},
|
||||
"related_surveys": {
|
||||
"facilities": {
|
||||
"name": "Annual Facilities Survey",
|
||||
"description": "Network infrastructure details for carriers owning/operating telecom facilities",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 31,
|
||||
"threshold": "Carriers owning or operating telecom network facilities",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
"pricing": {
|
||||
"name": "Annual Communications Pricing Survey",
|
||||
"description": "Pricing data for telecom services offered",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 31,
|
||||
"threshold": "Carriers with >$10M CAD annual Canadian telecom revenue",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BC Corporate Tax & Filing Obligations
|
||||
# Assumes calendar fiscal year-end (Dec 31). If the client chooses a
|
||||
# non-standard fiscal year, these dates need adjustment.
|
||||
# ------------------------------------------------------------------ #
|
||||
"corporate_obligations": {
|
||||
"t2_return": {
|
||||
"name": "Federal T2 Corporate Income Tax Return",
|
||||
"description": (
|
||||
"All Canadian corporations must file a T2 return with the CRA, "
|
||||
"even if there is no tax owing or no business activity. "
|
||||
"Filed electronically via CRA My Business Account or certified tax software."
|
||||
),
|
||||
"deadline_description": "6 months after fiscal year-end",
|
||||
"deadline_month": 6, # June 30 for Dec 31 year-end
|
||||
"deadline_day": 30,
|
||||
"required": True,
|
||||
"penalty": "5% of unpaid tax + 1%/month for up to 12 months",
|
||||
"cra_url": "https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/corporations/corporation-income-tax-return.html",
|
||||
},
|
||||
"t2_tax_payment": {
|
||||
"name": "Federal/Provincial Corporate Tax Payment",
|
||||
"description": (
|
||||
"Corporate income tax balance owing is due earlier than the T2 return. "
|
||||
"For Canadian-Controlled Private Corporations (CCPCs) with taxable income "
|
||||
"under $500K, payment is due 3 months after year-end. "
|
||||
"Interest accrues on late payments."
|
||||
),
|
||||
"deadline_description": "3 months after fiscal year-end (CCPCs under $500K)",
|
||||
"deadline_month": 3, # March 31 for Dec 31 year-end
|
||||
"deadline_day": 31,
|
||||
"required": True,
|
||||
},
|
||||
"gst_hst_return": {
|
||||
"name": "GST/HST Return",
|
||||
"description": (
|
||||
"If registered for GST/HST (required if revenue > $30K/yr, "
|
||||
"recommended to register voluntarily for input tax credits). "
|
||||
"Annual filers: due 3 months after fiscal year-end. "
|
||||
"Telecom services are generally GST/HST taxable."
|
||||
),
|
||||
"deadline_description": "3 months after fiscal year-end (annual filers)",
|
||||
"deadline_month": 3, # March 31 for Dec 31 year-end
|
||||
"deadline_day": 31,
|
||||
"threshold": "Mandatory if >$30K revenue; voluntary registration recommended",
|
||||
"required_for_new_carriers": True, # should register voluntarily
|
||||
},
|
||||
"t4_t4a_slips": {
|
||||
"name": "T4/T4A Information Slips",
|
||||
"description": (
|
||||
"If the corporation has employees or contractors paid >$500/yr, "
|
||||
"T4 (employment) and/or T4A (contractor) slips must be filed. "
|
||||
"Not applicable for most new shell telecom corporations."
|
||||
),
|
||||
"deadline_description": "Last day of February following the calendar year",
|
||||
"deadline_month": 2,
|
||||
"deadline_day": 28,
|
||||
"threshold": "Only if corporation has employees or pays contractors >$500/yr",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
"bc_pst": {
|
||||
"name": "BC Provincial Sales Tax (PST) Return",
|
||||
"description": (
|
||||
"Most telecom services in BC are subject to 7% PST. "
|
||||
"If registered as a PST collector, returns are due monthly, "
|
||||
"quarterly, or annually depending on volume. "
|
||||
"New carriers should consult with an accountant about PST obligations."
|
||||
),
|
||||
"threshold": "If collecting PST on taxable telecom services",
|
||||
"required_for_new_carriers": False, # depends on service type
|
||||
},
|
||||
"worksafebc": {
|
||||
"name": "WorkSafeBC Annual Return",
|
||||
"description": (
|
||||
"Required if the corporation has employees in BC. "
|
||||
"Annual return reports payroll for workers' compensation premium calculation. "
|
||||
"Not applicable for corporations with no employees."
|
||||
),
|
||||
"deadline_description": "March 1 following the calendar year",
|
||||
"deadline_month": 3,
|
||||
"deadline_day": 1,
|
||||
"threshold": "Only if corporation has BC employees",
|
||||
"required_for_new_carriers": False,
|
||||
},
|
||||
"crtc_registration_update": {
|
||||
"name": "CRTC Annual Registration Update",
|
||||
"description": (
|
||||
"The CRTC contacts registered carriers annually to verify and update "
|
||||
"registration information. Must respond to maintain active registration status. "
|
||||
"The CRTC initiates this — you just need to respond."
|
||||
),
|
||||
"deadline_description": "Respond within 30 days of CRTC contact (typically Q1)",
|
||||
"required": True,
|
||||
},
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Fees (CAD)
|
||||
# ------------------------------------------------------------------ #
|
||||
"fees": {
|
||||
"name_reservation": 30.00,
|
||||
"incorporation": 350.00,
|
||||
"annual_report": 42.00,
|
||||
"mailbox_yearly": 164.99,
|
||||
"currency": "CAD",
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Playwright selectors — BC Corporate Online (Struts / classic HTML)
|
||||
#
|
||||
# IMPORTANT NOTES ON THE PORTAL:
|
||||
# - No login required for Incorporation Application (anonymous filing)
|
||||
# - All form fields follow Struts DTO naming: fedDto.*
|
||||
# - Wizard is a multi-page POST flow; Playwright must follow the
|
||||
# "Next" / "Continue" buttons between pages
|
||||
# - Payment is by credit card — use Relay virtual debit card
|
||||
# - After payment, the BC incorporation number appears on the receipt
|
||||
#
|
||||
# Selector status:
|
||||
# ✓ CONFIRMED from live portal HTML (fetched 2026-04-04)
|
||||
# ~ INFERRED from Struts DTO naming convention + wizard step structure
|
||||
# ✗ UNVERIFIED — needs manual inspection in a live session
|
||||
# ------------------------------------------------------------------ #
|
||||
"selectors": {
|
||||
|
||||
# ── Step 0: No login required ────────────────────────────────────
|
||||
# Navigate directly to icorp_start_url (anonymous)
|
||||
"login_username": "", # Not used — portal is anonymous
|
||||
"login_password": "", # Not used
|
||||
"login_submit": "", # Not used
|
||||
|
||||
# ── Step 1: Initial Information ──────────────────────────────────
|
||||
# URL: .../menu.do?action=startFiling&filingTypeCode=ICORP&from=main
|
||||
# Confirmed from live HTML fetch 2026-04-04:
|
||||
# - Radio button for numbered company: value="NMBRD"
|
||||
# - Name reservation number input field is present
|
||||
# - Effective date selects: fedDto.effectiveDateTime.*
|
||||
"inc_numbered_company_radio": "input[type='radio'][value='NMBRD']", # ✓ confirmed
|
||||
"inc_nr_number": "input[name='fedDto.nameReservationNumber']", # ~ inferred
|
||||
"inc_effective_immediately": "input[type='radio'][value='immediate']", # ~ inferred
|
||||
"inc_next_btn": "input[type='submit'][value='Next >']", # ~ inferred
|
||||
|
||||
# ── Step 2: Incorporator Info ────────────────────────────────────
|
||||
# Who is incorporating the company. We list Performance West Inc.
|
||||
# as the incorporator (agent on behalf of client).
|
||||
"inc_incorporator_first": "input[name='fedDto.incorporatorDto.firstName']", # ~ inferred
|
||||
"inc_incorporator_last": "input[name='fedDto.incorporatorDto.lastName']", # ~ inferred
|
||||
"inc_incorporator_org": "input[name='fedDto.incorporatorDto.orgName']", # ~ inferred (org name)
|
||||
"inc_incorporator_addr1": "input[name='fedDto.incorporatorDto.address.addr1']", # ~ inferred
|
||||
"inc_incorporator_city": "input[name='fedDto.incorporatorDto.address.city']", # ~ inferred
|
||||
"inc_incorporator_prov": "select[name='fedDto.incorporatorDto.address.province']", # ~ inferred
|
||||
"inc_incorporator_postal":"input[name='fedDto.incorporatorDto.address.postalCd']", # ~ inferred
|
||||
"inc_incorporator_country":"select[name='fedDto.incorporatorDto.address.country']", # ~ inferred
|
||||
|
||||
# ── Step 3: Completing Party ─────────────────────────────────────
|
||||
# Person completing this filing (same as incorporator for us).
|
||||
"inc_completing_same_chk": "input[type='checkbox'][name*='sameasincorporator' i]", # ~ inferred
|
||||
"inc_completing_first": "input[name='fedDto.completingPartyDto.firstName']", # ~ inferred
|
||||
"inc_completing_last": "input[name='fedDto.completingPartyDto.lastName']", # ~ inferred
|
||||
"inc_completing_phone": "input[name='fedDto.completingPartyDto.phoneNumber']", # ~ inferred
|
||||
"inc_completing_email": "input[name='fedDto.completingPartyDto.email']", # ~ inferred
|
||||
|
||||
# ── Step 5: Director Info ────────────────────────────────────────
|
||||
# Director 1 (the client is typically sole director)
|
||||
# These are the UNVERIFIED selectors flagged in adapter.py.
|
||||
"inc_director_name": "input[name='fedDto.directorDtos[0].fullName']", # ✗ unverified
|
||||
"inc_director_first": "input[name='fedDto.directorDtos[0].firstName']", # ✗ unverified
|
||||
"inc_director_last": "input[name='fedDto.directorDtos[0].lastName']", # ✗ unverified
|
||||
"inc_director_addr1": "input[name='fedDto.directorDtos[0].address.addr1']", # ✗ unverified
|
||||
"inc_director_city": "input[name='fedDto.directorDtos[0].address.city']", # ✗ unverified
|
||||
"inc_director_prov": "select[name='fedDto.directorDtos[0].address.province']",# ✗ unverified
|
||||
"inc_director_postal": "input[name='fedDto.directorDtos[0].address.postalCd']", # ✗ unverified
|
||||
"inc_director_country": "select[name='fedDto.directorDtos[0].address.country']", # ✗ unverified
|
||||
# Legacy combined field (some COLIN versions use a single fullName input)
|
||||
"inc_director_address": "input[name='fedDto.directorDtos[0].address.addr1']", # ✗ unverified
|
||||
|
||||
# ── Step 6: Office Addresses ─────────────────────────────────────
|
||||
# Registered office = Anytime Mailbox address + unit number
|
||||
"inc_registered_office_street": "input[name='fedDto.regOfficeDto.deliveryAddress.addr1']", # ✗ unverified
|
||||
"inc_registered_office_city": "input[name='fedDto.regOfficeDto.deliveryAddress.city']", # ✗ unverified
|
||||
"inc_registered_office_province":"select[name='fedDto.regOfficeDto.deliveryAddress.province']", # ✗ unverified
|
||||
"inc_registered_office_postal": "input[name='fedDto.regOfficeDto.deliveryAddress.postalCd']", # ✗ unverified
|
||||
# Records office same as registered — typical checkbox
|
||||
"inc_records_office_same": "input[type='checkbox'][name*='recordsSame' i]", # ✗ unverified
|
||||
# company_name is shown as a label here; not a text input at this step
|
||||
"inc_company_name": "input[name='fedDto.nameReservationNumber']", # only for NR# path
|
||||
|
||||
# ── Step 7: Share Structure ──────────────────────────────────────
|
||||
# Standard structure: 1 class, "Common Shares", unlimited, no par value
|
||||
# COLIN presents a pre-filled Table 1 Articles option (checkbox to adopt)
|
||||
"inc_share_structure": "input[type='checkbox'][name*='adoptTable1' i]", # ✗ unverified
|
||||
"inc_table1_adopt": "input[type='checkbox'][name*='adoptTable1' i]", # ✗ unverified
|
||||
"inc_share_class_name": "input[name='fedDto.shareDtos[0].className']", # ✗ unverified
|
||||
"inc_share_max": "input[name='fedDto.shareDtos[0].maxShares']", # ✗ unverified
|
||||
# Articles file upload — only needed if NOT using Table 1
|
||||
"inc_articles": "input[type='file'][name*='articles' i]", # ✗ unverified
|
||||
|
||||
# ── Step 8: Notification ─────────────────────────────────────────
|
||||
"inc_notification_email": "input[name='fedDto.notificationEmail']", # ~ inferred
|
||||
|
||||
# ── Step 11: Ready to Pay (credit card) ──────────────────────────
|
||||
# COLIN uses a standard card form at checkout
|
||||
"pay_card_number": "input[name='cardNumber'], input[id*='cardNumber' i], input[autocomplete='cc-number']", # ✗ unverified
|
||||
"pay_card_exp": "input[name='expiryDate'], input[id*='expiry' i], input[autocomplete='cc-exp']", # ✗ unverified
|
||||
"pay_card_cvv": "input[name='cvv'], input[name='cvd'], input[id*='cvv' i], input[autocomplete='cc-csc']", # ✗ unverified
|
||||
"pay_card_name": "input[name='cardholderName'], input[id*='cardHolder' i], input[autocomplete='cc-name']", # ✗ unverified
|
||||
"pay_submit": "input[type='submit'][value*='Pay' i], button:has-text('Pay Now'), input[type='submit'][value*='Submit Payment' i]", # ✗ unverified
|
||||
|
||||
# ── Step 12: Submit / Confirmation ───────────────────────────────
|
||||
"inc_submit": "input[type='submit'][value*='Submit' i], input[type='submit'][value*='Confirm' i]", # ✗ unverified
|
||||
# Confirmation / receipt page — BC incorporation number
|
||||
"inc_confirmation_number": ".confirmation-number, td:has-text('Incorporation Number') + td, td.dataValue", # ✗ unverified
|
||||
|
||||
# ── Name Request portal (bcregistrynames.gov.bc.ca) ──────────────
|
||||
# Modern Vue.js SPA — selectors are data-test or aria attributes
|
||||
"name_search_input": "input[id='business-name'], input[placeholder*='Enter name' i], input[data-test='business-name']", # ~ inferred
|
||||
"name_search_submit": "button[data-test='search-btn'], button:has-text('Search')", # ~ inferred
|
||||
"name_result_available": ".v-chip--label:has-text('Available'), .available-badge, [class*='available']", # ~ inferred
|
||||
"name_result_unavailable":"[class*='not-available'], [class*='unavailable'], .v-chip:has-text('Not Available')", # ~ inferred
|
||||
"name_reserve_btn": "button:has-text('Reserve'), button[data-test='reserve-btn']", # ~ inferred
|
||||
|
||||
# ── Anytime Mailbox ───────────────────────────────────────────────
|
||||
"amb_location_search": "input[placeholder*='city' i], input[placeholder*='search' i]",
|
||||
"amb_email": "input[type='email'], input[name*='email' i]",
|
||||
"amb_password": "input[type='password'], input[name*='password' i]",
|
||||
"amb_phone": "input[type='tel'], input[name*='phone' i]",
|
||||
"amb_first_name": "input[name*='firstName' i], input[name*='first_name' i], input[placeholder*='First name' i]",
|
||||
"amb_last_name": "input[name*='lastName' i], input[name*='last_name' i], input[placeholder*='Last name' i]",
|
||||
"amb_business_name": "input[name*='business' i], input[placeholder*='Business name' i]",
|
||||
"amb_home_address": "input[name*='address' i]:not([name*='email' i]), input[placeholder*='Street' i]",
|
||||
"amb_home_city": "input[name*='city' i]",
|
||||
"amb_home_state": "select[name*='state' i], select[name*='province' i]",
|
||||
"amb_home_postal": "input[name*='postal' i], input[name*='zip' i]",
|
||||
"amb_plan_select": "button:has-text('Select'), a:has-text('Select')",
|
||||
"amb_plan_period_yearly": "input[type='radio'][value*='year' i], label:has-text('Yearly')",
|
||||
"amb_location_select": "button:has-text('Choose'), button:has-text('Select this location')",
|
||||
"amb_mailbox_number_first":"select option:first-child, .mailbox-select option:nth-child(2)",
|
||||
"amb_continue": "button:has-text('Continue'), button:has-text('Next')",
|
||||
"amb_otp": "input[name*='otp' i], input[name*='code' i], input[placeholder*='verification' i]",
|
||||
"amb_otp_submit": "button:has-text('Verify'), button:has-text('Submit')",
|
||||
"amb_checkout_submit": "button:has-text('Complete'), button:has-text('Subscribe'), button:has-text('Pay')",
|
||||
|
||||
# ── Annual Report ─────────────────────────────────────────────────
|
||||
"ar_filing_year": "select[name*='year' i], input[name*='year' i]",
|
||||
"ar_confirm_address": "input[type='checkbox'][name*='confirm' i]",
|
||||
"ar_submit": "input[type='submit'], button[type='submit']",
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Selector verification status
|
||||
# Tracks which step selectors have been confirmed against the live portal.
|
||||
# The adapter checks this before running to prevent half-complete filings.
|
||||
# ------------------------------------------------------------------ #
|
||||
"COLIN_UNVERIFIED_STEP_SELECTORS": {
|
||||
# Steps 5-12 need live session verification.
|
||||
# Remove a step once confirmed to unblock that part of the pipeline.
|
||||
5: ["inc_director_first", "inc_director_last", "inc_director_addr1"],
|
||||
6: ["inc_registered_office_street", "inc_records_office_same"],
|
||||
7: ["inc_share_structure", "inc_table1_adopt"],
|
||||
9: ["pay_card_number", "pay_card_exp", "pay_card_cvv", "pay_submit"],
|
||||
12: ["inc_submit", "inc_confirmation_number"],
|
||||
},
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Notes
|
||||
# ------------------------------------------------------------------ #
|
||||
"notes": (
|
||||
"BC Business Corporations Act (SBC 2002, c. 57) requirements:\n"
|
||||
" - Must have at least one director (can be non-resident).\n"
|
||||
" - Registered office AND records office must be in BC.\n"
|
||||
" - We use Anytime Mailbox at client's chosen BC location as both.\n"
|
||||
" - Name reservation is optional but recommended (valid 56 days).\n"
|
||||
" - Numbered companies do not require a name reservation.\n"
|
||||
" - Annual Report due within 2 months of anniversary date.\n"
|
||||
" - CRTC notification required for telecom service providers.\n"
|
||||
" - All fees in Canadian Dollars (CAD).\n"
|
||||
" - Corporate Online filing portal is ANONYMOUS — no login required.\n"
|
||||
" Payment by Visa/MC/Amex at the end of the wizard.\n"
|
||||
" Use Relay virtual debit card (SID-0002) for filing payment."
|
||||
),
|
||||
}
|
||||
2
scripts/formation/states/ca/__init__.py
Normal file
2
scripts/formation/states/ca/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
119
scripts/formation/states/ca/adapter.py
Normal file
119
scripts/formation/states/ca/adapter.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""California — SOS portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class CAPortal(StatePortal):
|
||||
STATE_CODE = "CA"
|
||||
STATE_NAME = "California"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search California business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in California."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement California-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
# NOTE: California imposes an annual franchise tax of $800/yr.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for California",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in California."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for California",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> CAPortal:
|
||||
return CAPortal()
|
||||
49
scripts/formation/states/ca/config.py
Normal file
49
scripts/formation/states/ca/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""California — Secretary of State portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "CA",
|
||||
"state_name": "California",
|
||||
"sos_name": "California Secretary of State",
|
||||
"portal_name": "California bizfile Online",
|
||||
"portal_url": "https://sos.ca.gov",
|
||||
"name_search_url": "https://businesssearch.sos.ca.gov",
|
||||
"filing_url": "https://bizfileonline.sos.ca.gov",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "1800 S Brand Blvd Ste 201",
|
||||
"nwra_city": "Glendale",
|
||||
"nwra_state": "CA",
|
||||
"nwra_zip": "91204",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 7000,
|
||||
"corp_formation_fee": 10000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "California imposes an annual franchise tax of $800/yr for LLCs and corporations.",
|
||||
}
|
||||
2
scripts/formation/states/co/__init__.py
Normal file
2
scripts/formation/states/co/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
177
scripts/formation/states/co/adapter.py
Normal file
177
scripts/formation/states/co/adapter.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""Colorado — Socrata API for name search, Playwright for filing.
|
||||
|
||||
Colorado publishes business entity data on data.colorado.gov via the
|
||||
Socrata Open Data API (SODA). Dataset ID: 4ykn-tg5h.
|
||||
This allows name availability searches WITHOUT a headless browser.
|
||||
Filing still requires Playwright against the SOS web portal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from scripts.formation.base import (
|
||||
StatePortal, NameSearchResult, FormationOrder, FilingResult,
|
||||
FilingStatus,
|
||||
)
|
||||
from .config import CONFIG
|
||||
|
||||
SOCRATA_BASE = "https://data.colorado.gov/resource/4ykn-tg5h.json"
|
||||
|
||||
|
||||
class COPortal(StatePortal):
|
||||
STATE_CODE = "CO"
|
||||
STATE_NAME = "Colorado"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Colorado business name availability via Socrata SODA API.
|
||||
|
||||
Uses the free REST API at data.colorado.gov — no browser, no login,
|
||||
no rate-limit issues for moderate usage. Returns JSON directly.
|
||||
"""
|
||||
try:
|
||||
# SoQL query: find entities whose name contains our search term
|
||||
upper_name = name.upper().replace("'", "''")
|
||||
query = f"$where=upper(entityname) like '%25{urllib.parse.quote(upper_name)}%25'"
|
||||
query += "&$limit=20&$order=entityformdate DESC"
|
||||
url = f"{SOCRATA_BASE}?{query}"
|
||||
|
||||
self.log.info("CO Socrata API query: %s", url)
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Accept": "application/json", "User-Agent": "PerformanceWest/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
# Check for exact match (case-insensitive)
|
||||
exact_match = any(
|
||||
r.get("entityname", "").upper().strip() == upper_name.strip()
|
||||
for r in data
|
||||
)
|
||||
|
||||
# Collect similar names
|
||||
similar_names = [
|
||||
r.get("entityname", "").strip()
|
||||
for r in data[:10]
|
||||
if r.get("entityname", "").strip()
|
||||
]
|
||||
|
||||
available = not exact_match
|
||||
|
||||
self.log.info(
|
||||
"CO name search: '%s' — %s (exact_match=%s, similar=%d)",
|
||||
name, "AVAILABLE" if available else "TAKEN", exact_match, len(similar_names),
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
exact_match=exact_match,
|
||||
similar_names=similar_names,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=json.dumps(data[:5]),
|
||||
)
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
self.log.error("CO Socrata API request failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=f"Socrata API error: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("CO name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=f"Error: {e}",
|
||||
)
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC Articles of Organization in Colorado.
|
||||
|
||||
Colorado SOS portal: sos.state.co.us
|
||||
Filing fee: $50
|
||||
Online filing is immediate (no processing delay).
|
||||
|
||||
TODO: Implement Playwright filing flow.
|
||||
"""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("co_llc_start")
|
||||
|
||||
# Verify name first via Socrata API (no browser needed)
|
||||
name_result = await self.search_name(order.entity_name)
|
||||
if not name_result.available:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.NAME_UNAVAILABLE,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=f"Name '{order.entity_name}' is not available in Colorado. "
|
||||
f"Similar: {', '.join(name_result.similar_names[:5])}",
|
||||
)
|
||||
|
||||
# TODO: Complete filing flow once portal selectors are mapped
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="CO LLC filing: name search via API works, "
|
||||
"filing form selectors pending portal walkthrough",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("CO LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Articles of Incorporation in Colorado ($50)."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="CO Corp filing pending — LLC flow first",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> COPortal:
|
||||
return COPortal()
|
||||
49
scripts/formation/states/co/config.py
Normal file
49
scripts/formation/states/co/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Colorado — Secretary of State portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "CO",
|
||||
"state_name": "Colorado",
|
||||
"sos_name": "Colorado Secretary of State",
|
||||
"portal_name": "Colorado Business Database",
|
||||
"portal_url": "https://sos.state.co.us",
|
||||
"name_search_url": "https://sos.state.co.us",
|
||||
"filing_url": "https://sos.state.co.us",
|
||||
"search_method": "socrata_api",
|
||||
# Socrata API
|
||||
"socrata_domain": "data.colorado.gov",
|
||||
"socrata_dataset_id": "4ykn-tg5h",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "7700 E Arapahoe Rd Ste 110",
|
||||
"nwra_city": "Centennial",
|
||||
"nwra_state": "CO",
|
||||
"nwra_zip": "80112",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 5000,
|
||||
"corp_formation_fee": 5000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/ct/__init__.py
Normal file
2
scripts/formation/states/ct/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ct/adapter.py
Normal file
118
scripts/formation/states/ct/adapter.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Connecticut — SOTS portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class CTPortal(StatePortal):
|
||||
STATE_CODE = "CT"
|
||||
STATE_NAME = "Connecticut"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Connecticut business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Connecticut."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Connecticut-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Connecticut",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Connecticut."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Connecticut",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> CTPortal:
|
||||
return CTPortal()
|
||||
49
scripts/formation/states/ct/config.py
Normal file
49
scripts/formation/states/ct/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Connecticut — Secretary of the State portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "CT",
|
||||
"state_name": "Connecticut",
|
||||
"sos_name": "Connecticut Secretary of the State",
|
||||
"portal_name": "Connecticut Online Business Search",
|
||||
"portal_url": "https://portal.ct.gov/sots",
|
||||
"name_search_url": "https://service.ct.gov/business/s/onlinebusinesssearch",
|
||||
"filing_url": "https://service.ct.gov/business/s/onlinebusinesssearch",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "40 Old Ridgebury Rd Ste 205",
|
||||
"nwra_city": "Danbury",
|
||||
"nwra_state": "CT",
|
||||
"nwra_zip": "06810",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 12000,
|
||||
"corp_formation_fee": 25000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/dc/__init__.py
Normal file
2
scripts/formation/states/dc/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
70
scripts/formation/states/dc/adapter.py
Normal file
70
scripts/formation/states/dc/adapter.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class DCPortal(StatePortal):
|
||||
"""District of Columbia DLCP portal adapter."""
|
||||
|
||||
config = CONFIG
|
||||
|
||||
async def search_name(self, page: Page, name: str) -> dict:
|
||||
"""Search the DC business name database.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
name: Business name to search for.
|
||||
|
||||
Returns:
|
||||
dict with 'available' (bool) and 'results' (list).
|
||||
"""
|
||||
await page.goto(CONFIG["search_url"])
|
||||
|
||||
search_input = CONFIG["selectors"]["search_input"]
|
||||
search_button = CONFIG["selectors"]["search_button"]
|
||||
results_table = CONFIG["selectors"]["results_table"]
|
||||
|
||||
if search_input:
|
||||
await page.fill(search_input, name)
|
||||
if search_button:
|
||||
await page.click(search_button)
|
||||
if results_table:
|
||||
await page.wait_for_selector(results_table)
|
||||
|
||||
return {"available": False, "results": [], "status": "not yet implemented"}
|
||||
|
||||
async def file_llc(self, page: Page, payload: dict) -> dict:
|
||||
"""File an LLC formation with the DC DLCP ($99).
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, members.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
async def file_corporation(self, page: Page, payload: dict) -> dict:
|
||||
"""File a Corporation formation with the DC DLCP ($99).
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, directors.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
|
||||
adapter = DCPortal()
|
||||
29
scripts/formation/states/dc/config.py
Normal file
29
scripts/formation/states/dc/config.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
CONFIG = {
|
||||
"state": "DC",
|
||||
"state_name": "District of Columbia",
|
||||
"agency": "DLCP",
|
||||
"agency_name": "Department of Licensing and Consumer Protection",
|
||||
"portal_url": "https://dcra.dc.gov",
|
||||
"search_url": "https://corponline.dcra.dc.gov/BizEntity.aspx/Search",
|
||||
"registered_agent": {
|
||||
"name": "Northwest Registered Agent",
|
||||
"street": "611 Pennsylvania Ave SE Ste 443",
|
||||
"city": "Washington",
|
||||
"state": "DC",
|
||||
"zip": "20003",
|
||||
},
|
||||
"fees": {
|
||||
"llc": 99,
|
||||
"corporation": 99,
|
||||
},
|
||||
"selectors": {
|
||||
"search_input": "",
|
||||
"search_button": "",
|
||||
"results_table": "",
|
||||
"name_field": "",
|
||||
"agent_name_field": "",
|
||||
"agent_address_field": "",
|
||||
"submit_button": "",
|
||||
},
|
||||
"notes": "$300 biennial report.",
|
||||
}
|
||||
2
scripts/formation/states/de/__init__.py
Normal file
2
scripts/formation/states/de/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
119
scripts/formation/states/de/adapter.py
Normal file
119
scripts/formation/states/de/adapter.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Delaware — Division of Corporations portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class DEPortal(StatePortal):
|
||||
STATE_CODE = "DE"
|
||||
STATE_NAME = "Delaware"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Delaware business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Delaware."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Delaware-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
# NOTE: Delaware imposes an annual franchise tax of $300/yr for LLCs.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Delaware",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Delaware."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Delaware",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> DEPortal:
|
||||
return DEPortal()
|
||||
68
scripts/formation/states/de/config.py
Normal file
68
scripts/formation/states/de/config.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Delaware — Division of Corporations portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "DE",
|
||||
"state_name": "Delaware",
|
||||
"sos_name": "Delaware Division of Corporations",
|
||||
"portal_name": "Delaware ICIS Entity Search",
|
||||
"portal_url": "https://corp.delaware.gov",
|
||||
"name_search_url": "https://icis.corp.delaware.gov/ecorp/entitysearch/namesearch.aspx",
|
||||
"filing_url": "https://icis.corp.delaware.gov/ecorp/entitysearch",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "8 The Green Ste A",
|
||||
"nwra_city": "Dover",
|
||||
"nwra_state": "DE",
|
||||
"nwra_zip": "19901",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 11000,
|
||||
"corp_formation_fee": 8900,
|
||||
"expedited_fee": 50000,
|
||||
"expedited_label": "24-hour",
|
||||
# VERIFIED selectors from live portal HTML (2026-03-19)
|
||||
"selectors": {
|
||||
# Name search (namesearch.aspx) — ASP.NET WebForms with __VIEWSTATE
|
||||
"name_search_input": "#ctl00_ContentPlaceHolder1_frmEntityName",
|
||||
"file_number_input": "#ctl00_ContentPlaceHolder1_frmFileNumber",
|
||||
"name_search_submit": "#ctl00_ContentPlaceHolder1_btnSubmit",
|
||||
"error_label": "#ctl00_ContentPlaceHolder1_lblError",
|
||||
"error_message": "#ctl00_ContentPlaceHolder1_lblErrorMessage",
|
||||
"name_results_table": "#tblResults",
|
||||
"name_available_indicator": "", # No results = name available
|
||||
"name_unavailable_indicator": "", # Results present = name taken
|
||||
# CAPTCHA
|
||||
"captcha_panel": "#ctl00_ContentPlaceHolder1_pnlCaptcha",
|
||||
"captcha_image_base": "/Ecorp/CaptchaHandler.ashx?type=image&key=",
|
||||
# Honeypot field (hidden)
|
||||
"honeypot_field": "input[name='email_confirm']",
|
||||
# LLC filing form selectors — NOT YET VERIFIED (requires active filing session)
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": (
|
||||
"Delaware imposes an annual franchise tax of $300/yr for LLCs. "
|
||||
"CRITICAL: Name search has CAPTCHA on every request (image-based, in pnlCaptcha div). "
|
||||
"Anti-scraping warning on portal: 'The Division of Corporations strictly prohibits mining data. "
|
||||
"Use of automated tools in any form may result in the suspension of your access.' "
|
||||
"Need 2captcha or anticaptcha integration for automated name search. "
|
||||
"Portal uses ASP.NET WebForms with __VIEWSTATE — must maintain session cookies. "
|
||||
"Hidden honeypot field 'email_confirm' must be left empty. "
|
||||
"JavaScript cookie 'js_token' set via btoa(Date.now()) required. "
|
||||
"$5,000 for 1-hour rush, $1,000 for same-day, $500 for 24-hour."
|
||||
),
|
||||
}
|
||||
2
scripts/formation/states/fl/__init__.py
Normal file
2
scripts/formation/states/fl/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/fl/adapter.py
Normal file
118
scripts/formation/states/fl/adapter.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Florida — Sunbiz portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class FLPortal(StatePortal):
|
||||
STATE_CODE = "FL"
|
||||
STATE_NAME = "Florida"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Florida business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Florida."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Florida-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Florida",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Florida."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Florida",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> FLPortal:
|
||||
return FLPortal()
|
||||
49
scripts/formation/states/fl/config.py
Normal file
49
scripts/formation/states/fl/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Florida — Division of Corporations (Sunbiz) portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "FL",
|
||||
"state_name": "Florida",
|
||||
"sos_name": "Florida Division of Corporations",
|
||||
"portal_name": "Sunbiz",
|
||||
"portal_url": "https://sunbiz.org",
|
||||
"name_search_url": "https://search.sunbiz.org/Inquiry/CorporationSearch/ByName",
|
||||
"filing_url": "https://sunbiz.org",
|
||||
"search_method": "sftp_bulk",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "8 S. Tennessee Ave Ste 104",
|
||||
"nwra_city": "Lakeland",
|
||||
"nwra_state": "FL",
|
||||
"nwra_zip": "33801",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 12500,
|
||||
"corp_formation_fee": 7000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/ga/__init__.py
Normal file
2
scripts/formation/states/ga/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
118
scripts/formation/states/ga/adapter.py
Normal file
118
scripts/formation/states/ga/adapter.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Georgia — SOS portal automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from scripts.formation.base import StatePortal, NameSearchResult, FormationOrder, FilingResult, FilingStatus
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class GAPortal(StatePortal):
|
||||
STATE_CODE = "GA"
|
||||
STATE_NAME = "Georgia"
|
||||
PORTAL_NAME = CONFIG["portal_name"]
|
||||
PORTAL_URL = CONFIG["portal_url"]
|
||||
NWRA_ADDRESS = CONFIG["nwra_address"]
|
||||
NWRA_CITY = CONFIG["nwra_city"]
|
||||
NWRA_STATE = CONFIG["nwra_state"]
|
||||
NWRA_ZIP = CONFIG["nwra_zip"]
|
||||
|
||||
async def search_name(self, name: str) -> NameSearchResult:
|
||||
"""Search Georgia business name availability."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["name_search_url"])
|
||||
await self.human_delay()
|
||||
|
||||
# Type name into search field
|
||||
sel = CONFIG["selectors"]
|
||||
if sel["name_search_input"]:
|
||||
await self.type_slowly(sel["name_search_input"], name)
|
||||
await self.safe_click(sel["name_search_submit"])
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
content = await page.content()
|
||||
available = CONFIG["selectors"]["name_unavailable_indicator"] not in content
|
||||
|
||||
return NameSearchResult(
|
||||
available=available,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=content[:2000],
|
||||
)
|
||||
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response="Selectors not yet configured for this state",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("Name search failed: %s", e)
|
||||
return NameSearchResult(
|
||||
available=False,
|
||||
state_code=self.STATE_CODE,
|
||||
searched_name=name,
|
||||
raw_response=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
||||
"""File LLC in Georgia."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
await self.screenshot("llc_start")
|
||||
|
||||
# TODO: Implement Georgia-specific LLC filing flow
|
||||
# Each state's portal has different form fields, steps, and workflows.
|
||||
# The selectors in config.py need to be populated by inspecting the portal.
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="LLC filing automation not yet implemented for Georgia",
|
||||
screenshot_path=await self.screenshot("llc_not_implemented"),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error("LLC filing failed: %s", e)
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
||||
"""File Corporation in Georgia."""
|
||||
try:
|
||||
page = await self.start_browser()
|
||||
await page.goto(CONFIG["filing_url"])
|
||||
await self.human_delay()
|
||||
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message="Corporation filing automation not yet implemented for Georgia",
|
||||
)
|
||||
except Exception as e:
|
||||
return FilingResult(
|
||||
success=False,
|
||||
status=FilingStatus.ERROR,
|
||||
state_code=self.STATE_CODE,
|
||||
entity_name=order.entity_name,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self.close_browser()
|
||||
|
||||
|
||||
def adapter() -> GAPortal:
|
||||
return GAPortal()
|
||||
49
scripts/formation/states/ga/config.py
Normal file
49
scripts/formation/states/ga/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Georgia — Secretary of State portal configuration."""
|
||||
|
||||
CONFIG = {
|
||||
"state_code": "GA",
|
||||
"state_name": "Georgia",
|
||||
"sos_name": "Georgia Secretary of State",
|
||||
"portal_name": "Georgia eCorp Business Search",
|
||||
"portal_url": "https://sos.ga.gov",
|
||||
"name_search_url": "https://ecorp.sos.ga.gov/BusinessSearch",
|
||||
"filing_url": "https://ecorp.sos.ga.gov/BusinessSearch",
|
||||
"search_method": "playwright",
|
||||
# Socrata API (not applicable)
|
||||
"socrata_domain": "",
|
||||
"socrata_dataset_id": "",
|
||||
# NW Registered Agent address in this state
|
||||
"nwra_name": "Northwest Registered Agent LLC",
|
||||
"nwra_address": "2985 Gordy Pkwy Ste 100",
|
||||
"nwra_city": "Marietta",
|
||||
"nwra_state": "GA",
|
||||
"nwra_zip": "30066",
|
||||
# State fees (cents)
|
||||
"llc_formation_fee": 11000,
|
||||
"corp_formation_fee": 11000,
|
||||
"expedited_fee": None,
|
||||
"expedited_label": "",
|
||||
# Selectors (Playwright CSS selectors for portal automation)
|
||||
"selectors": {
|
||||
"name_search_input": "",
|
||||
"name_search_submit": "",
|
||||
"name_results_table": "",
|
||||
"name_available_indicator": "",
|
||||
"name_unavailable_indicator": "",
|
||||
# LLC filing form selectors
|
||||
"llc_name_field": "",
|
||||
"llc_agent_name_field": "",
|
||||
"llc_agent_address_field": "",
|
||||
"llc_principal_address_field": "",
|
||||
"llc_organizer_name_field": "",
|
||||
"llc_management_type_select": "",
|
||||
"llc_purpose_field": "",
|
||||
"llc_submit_button": "",
|
||||
# Corp filing form selectors
|
||||
"corp_name_field": "",
|
||||
"corp_agent_name_field": "",
|
||||
"corp_shares_field": "",
|
||||
"corp_submit_button": "",
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
2
scripts/formation/states/hi/__init__.py
Normal file
2
scripts/formation/states/hi/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/hi/adapter.py
Normal file
71
scripts/formation/states/hi/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class HIPortal(StatePortal):
|
||||
"""Hawaii Business Registration Division portal adapter."""
|
||||
|
||||
config = CONFIG
|
||||
|
||||
async def search_name(self, page: Page, name: str) -> dict:
|
||||
"""Search the Hawaii business name database.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
name: Business name to search for.
|
||||
|
||||
Returns:
|
||||
dict with 'available' (bool) and 'results' (list).
|
||||
"""
|
||||
await page.goto(CONFIG["search_url"])
|
||||
|
||||
# TODO: populate selectors during portal inspection
|
||||
search_input = CONFIG["selectors"]["search_input"]
|
||||
search_button = CONFIG["selectors"]["search_button"]
|
||||
results_table = CONFIG["selectors"]["results_table"]
|
||||
|
||||
if search_input:
|
||||
await page.fill(search_input, name)
|
||||
if search_button:
|
||||
await page.click(search_button)
|
||||
if results_table:
|
||||
await page.wait_for_selector(results_table)
|
||||
|
||||
return {"available": False, "results": [], "status": "not yet implemented"}
|
||||
|
||||
async def file_llc(self, page: Page, payload: dict) -> dict:
|
||||
"""File an LLC formation with the Hawaii BREG.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, members.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
async def file_corporation(self, page: Page, payload: dict) -> dict:
|
||||
"""File a Corporation formation with the Hawaii BREG.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, directors.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
|
||||
adapter = HIPortal()
|
||||
28
scripts/formation/states/hi/config.py
Normal file
28
scripts/formation/states/hi/config.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CONFIG = {
|
||||
"state": "HI",
|
||||
"state_name": "Hawaii",
|
||||
"agency": "BREG",
|
||||
"agency_name": "Business Registration Division",
|
||||
"portal_url": "https://cca.hawaii.gov/breg",
|
||||
"search_url": "https://hbe.ehawaii.gov/documents/search.html",
|
||||
"registered_agent": {
|
||||
"name": "Northwest Registered Agent",
|
||||
"street": "1003 Bishop St Ste 1400",
|
||||
"city": "Honolulu",
|
||||
"state": "HI",
|
||||
"zip": "96813",
|
||||
},
|
||||
"fees": {
|
||||
"llc": 50,
|
||||
"corporation": 50,
|
||||
},
|
||||
"selectors": {
|
||||
"search_input": "",
|
||||
"search_button": "",
|
||||
"results_table": "",
|
||||
"name_field": "",
|
||||
"agent_name_field": "",
|
||||
"agent_address_field": "",
|
||||
"submit_button": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/ia/__init__.py
Normal file
2
scripts/formation/states/ia/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
80
scripts/formation/states/ia/adapter.py
Normal file
80
scripts/formation/states/ia/adapter.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class IAPortal(StatePortal):
|
||||
"""Iowa Secretary of State portal adapter."""
|
||||
|
||||
config = CONFIG
|
||||
|
||||
async def search_name(self, page: Page, name: str) -> dict:
|
||||
"""Search the Iowa business name database.
|
||||
|
||||
Uses Socrata open data API (data.iowa.gov) when available,
|
||||
falls back to the SOS web portal.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
name: Business name to search for.
|
||||
|
||||
Returns:
|
||||
dict with 'available' (bool) and 'results' (list).
|
||||
"""
|
||||
search_method = CONFIG.get("search_method", "web")
|
||||
|
||||
if search_method == "socrata":
|
||||
# TODO: implement Socrata API search against data.iowa.gov
|
||||
return {"available": False, "results": [], "status": "not yet implemented"}
|
||||
|
||||
await page.goto(CONFIG["search_url"])
|
||||
|
||||
# TODO: populate selectors during portal inspection
|
||||
search_input = CONFIG["selectors"]["search_input"]
|
||||
search_button = CONFIG["selectors"]["search_button"]
|
||||
results_table = CONFIG["selectors"]["results_table"]
|
||||
|
||||
if search_input:
|
||||
await page.fill(search_input, name)
|
||||
if search_button:
|
||||
await page.click(search_button)
|
||||
if results_table:
|
||||
await page.wait_for_selector(results_table)
|
||||
|
||||
return {"available": False, "results": [], "status": "not yet implemented"}
|
||||
|
||||
async def file_llc(self, page: Page, payload: dict) -> dict:
|
||||
"""File an LLC formation with the Iowa SOS.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, members.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
async def file_corporation(self, page: Page, payload: dict) -> dict:
|
||||
"""File a Corporation formation with the Iowa SOS.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, directors.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
|
||||
adapter = IAPortal()
|
||||
30
scripts/formation/states/ia/config.py
Normal file
30
scripts/formation/states/ia/config.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
CONFIG = {
|
||||
"state": "IA",
|
||||
"state_name": "Iowa",
|
||||
"agency": "SOS",
|
||||
"agency_name": "Secretary of State",
|
||||
"portal_url": "https://sos.iowa.gov",
|
||||
"search_url": "https://sos.iowa.gov/search/business/search.aspx",
|
||||
"search_method": "socrata",
|
||||
"socrata_domain": "data.iowa.gov",
|
||||
"registered_agent": {
|
||||
"name": "Northwest Registered Agent",
|
||||
"street": "1550 2nd Ave SE Ste 200",
|
||||
"city": "Cedar Rapids",
|
||||
"state": "IA",
|
||||
"zip": "52401",
|
||||
},
|
||||
"fees": {
|
||||
"llc": 50,
|
||||
"corporation": 50,
|
||||
},
|
||||
"selectors": {
|
||||
"search_input": "",
|
||||
"search_button": "",
|
||||
"results_table": "",
|
||||
"name_field": "",
|
||||
"agent_name_field": "",
|
||||
"agent_address_field": "",
|
||||
"submit_button": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/id/__init__.py
Normal file
2
scripts/formation/states/id/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/id/adapter.py
Normal file
71
scripts/formation/states/id/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class IDPortal(StatePortal):
|
||||
"""Idaho Secretary of State portal adapter."""
|
||||
|
||||
config = CONFIG
|
||||
|
||||
async def search_name(self, page: Page, name: str) -> dict:
|
||||
"""Search the Idaho business name database.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
name: Business name to search for.
|
||||
|
||||
Returns:
|
||||
dict with 'available' (bool) and 'results' (list).
|
||||
"""
|
||||
await page.goto(CONFIG["search_url"])
|
||||
|
||||
# TODO: populate selectors during portal inspection
|
||||
search_input = CONFIG["selectors"]["search_input"]
|
||||
search_button = CONFIG["selectors"]["search_button"]
|
||||
results_table = CONFIG["selectors"]["results_table"]
|
||||
|
||||
if search_input:
|
||||
await page.fill(search_input, name)
|
||||
if search_button:
|
||||
await page.click(search_button)
|
||||
if results_table:
|
||||
await page.wait_for_selector(results_table)
|
||||
|
||||
return {"available": False, "results": [], "status": "not yet implemented"}
|
||||
|
||||
async def file_llc(self, page: Page, payload: dict) -> dict:
|
||||
"""File an LLC formation with the Idaho SOS.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, members.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
async def file_corporation(self, page: Page, payload: dict) -> dict:
|
||||
"""File a Corporation formation with the Idaho SOS.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance.
|
||||
payload: Formation data including name, agent, directors.
|
||||
|
||||
Returns:
|
||||
dict with filing confirmation or error details.
|
||||
"""
|
||||
await page.goto(CONFIG["portal_url"])
|
||||
|
||||
# TODO: implement actual filing flow during portal inspection
|
||||
return {"filed": False, "status": "not yet implemented"}
|
||||
|
||||
|
||||
adapter = IDPortal()
|
||||
28
scripts/formation/states/id/config.py
Normal file
28
scripts/formation/states/id/config.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CONFIG = {
|
||||
"state": "ID",
|
||||
"state_name": "Idaho",
|
||||
"agency": "SOS",
|
||||
"agency_name": "Secretary of State",
|
||||
"portal_url": "https://sos.idaho.gov",
|
||||
"search_url": "https://sosbiz.idaho.gov/search/business",
|
||||
"registered_agent": {
|
||||
"name": "Northwest Registered Agent",
|
||||
"street": "5680 E Franklin Rd Ste 250",
|
||||
"city": "Nampa",
|
||||
"state": "ID",
|
||||
"zip": "83687",
|
||||
},
|
||||
"fees": {
|
||||
"llc": 100,
|
||||
"corporation": 100,
|
||||
},
|
||||
"selectors": {
|
||||
"search_input": "",
|
||||
"search_button": "",
|
||||
"results_table": "",
|
||||
"name_field": "",
|
||||
"agent_name_field": "",
|
||||
"agent_address_field": "",
|
||||
"submit_button": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/il/__init__.py
Normal file
2
scripts/formation/states/il/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
80
scripts/formation/states/il/adapter.py
Normal file
80
scripts/formation/states/il/adapter.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
30
scripts/formation/states/il/config.py
Normal file
30
scripts/formation/states/il/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/in/__init__.py
Normal file
2
scripts/formation/states/in/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/in/adapter.py
Normal file
71
scripts/formation/states/in/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
28
scripts/formation/states/in/config.py
Normal file
28
scripts/formation/states/in/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/ks/__init__.py
Normal file
2
scripts/formation/states/ks/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/ks/adapter.py
Normal file
71
scripts/formation/states/ks/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
28
scripts/formation/states/ks/config.py
Normal file
28
scripts/formation/states/ks/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/ky/__init__.py
Normal file
2
scripts/formation/states/ky/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/ky/adapter.py
Normal file
71
scripts/formation/states/ky/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
28
scripts/formation/states/ky/config.py
Normal file
28
scripts/formation/states/ky/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/la/__init__.py
Normal file
2
scripts/formation/states/la/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/la/adapter.py
Normal file
71
scripts/formation/states/la/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
28
scripts/formation/states/la/config.py
Normal file
28
scripts/formation/states/la/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/ma/__init__.py
Normal file
2
scripts/formation/states/ma/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
73
scripts/formation/states/ma/adapter.py
Normal file
73
scripts/formation/states/ma/adapter.py
Normal 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()
|
||||
28
scripts/formation/states/ma/config.py
Normal file
28
scripts/formation/states/ma/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/md/__init__.py
Normal file
2
scripts/formation/states/md/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/md/adapter.py
Normal file
71
scripts/formation/states/md/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
28
scripts/formation/states/md/config.py
Normal file
28
scripts/formation/states/md/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/me/__init__.py
Normal file
2
scripts/formation/states/me/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/me/adapter.py
Normal file
71
scripts/formation/states/me/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
28
scripts/formation/states/me/config.py
Normal file
28
scripts/formation/states/me/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/mi/__init__.py
Normal file
2
scripts/formation/states/mi/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
80
scripts/formation/states/mi/adapter.py
Normal file
80
scripts/formation/states/mi/adapter.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
30
scripts/formation/states/mi/config.py
Normal file
30
scripts/formation/states/mi/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/mn/__init__.py
Normal file
2
scripts/formation/states/mn/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/mn/adapter.py
Normal file
71
scripts/formation/states/mn/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
29
scripts/formation/states/mn/config.py
Normal file
29
scripts/formation/states/mn/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/mo/__init__.py
Normal file
2
scripts/formation/states/mo/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/mo/adapter.py
Normal file
71
scripts/formation/states/mo/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
29
scripts/formation/states/mo/config.py
Normal file
29
scripts/formation/states/mo/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/ms/__init__.py
Normal file
2
scripts/formation/states/ms/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
71
scripts/formation/states/ms/adapter.py
Normal file
71
scripts/formation/states/ms/adapter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from scripts.formation.base import StatePortal
|
||||
from .config import CONFIG
|
||||
|
||||
|
||||
class 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()
|
||||
29
scripts/formation/states/ms/config.py
Normal file
29
scripts/formation/states/ms/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
2
scripts/formation/states/mt/__init__.py
Normal file
2
scripts/formation/states/mt/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .adapter import adapter
|
||||
from .config import CONFIG
|
||||
116
scripts/formation/states/mt/adapter.py
Normal file
116
scripts/formation/states/mt/adapter.py
Normal 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()
|
||||
32
scripts/formation/states/mt/config.py
Normal file
32
scripts/formation/states/mt/config.py
Normal 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": "",
|
||||
},
|
||||
}
|
||||
4
scripts/formation/states/nc/__init__.py
Normal file
4
scripts/formation/states/nc/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .config import CONFIG
|
||||
from .adapter import NCPortal
|
||||
|
||||
__all__ = ["CONFIG", "NCPortal"]
|
||||
22
scripts/formation/states/nc/adapter.py
Normal file
22
scripts/formation/states/nc/adapter.py
Normal 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)
|
||||
20
scripts/formation/states/nc/config.py
Normal file
20
scripts/formation/states/nc/config.py
Normal 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.",
|
||||
}
|
||||
4
scripts/formation/states/nd/__init__.py
Normal file
4
scripts/formation/states/nd/__init__.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue