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
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue