new-site/scripts/formation/states/bc/adapter.py
justin f8cd37ac8c 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>
2026-04-27 06:54:22 -05:00

977 lines
41 KiB
Python

"""
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()