Initial commit — Performance West telecom compliance platform

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

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

View file

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

View file

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

View file

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