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