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>
977 lines
41 KiB
Python
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()
|