new-site/scripts/workers/services/canada_crtc.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

2372 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Canada CRTC formation service worker.
Handles the full formation pipeline for a Canadian (BC or Ontario) corporation
intended for CRTC-regulated telecom services.
8-step flow:
1. Setup Anytime Mailbox (provincial registered office)
2. Name reservation (if named company — skip for numbered cos)
3. Provincial incorporation (BC Corporate Online or Ontario OBR)
4. Generate CRTC registration/notification letter (DOCX → PDF)
5. Compile corporate binder (all documents → single PDF)
6. Upload binder to MinIO
7. Email binder to client + print/ship instructions to justin@
8. Create compliance calendar entries (renewals & annual report)
Environment variables:
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD — outbound email
MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_BUCKET
ERPNEXT_URL, ERPNEXT_API_KEY, ERPNEXT_API_SECRET
STRIPE_SECRET_KEY — for payment processing
"""
from __future__ import annotations
import logging
import os
import smtplib
import tempfile
import time
from datetime import datetime, timedelta, timezone
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from typing import Any
from minio import Minio
from scripts.formation.base import EntityType, FilingStatus, FormationOrder, Member
from scripts.formation.states import get_adapter, get_config
from scripts.formation.states.bc.config import CONFIG as BC_CONFIG # backward compat default
from scripts.workers.services.base_handler import BaseServiceHandler
# Province display names for email copy
PROVINCE_NAMES = {"BC": "British Columbia", "ON": "Ontario"}
# Numbered company suffixes by province
NUMBERED_SUFFIX = {"BC": "B.C. Ltd.", "ON": "Ontario Inc."}
LOG = logging.getLogger("workers.canada_crtc")
def await_or_run(coro):
"""Run an async coroutine from sync code. Handles nested event loop."""
import asyncio
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# Already in an event loop — use nest_asyncio or create a thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
return pool.submit(asyncio.run, coro).result(timeout=120)
else:
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# Portal JWT helper (mirrors api/src/middleware/portalAuth.ts)
# Used to generate signed eSign links for the client
# --------------------------------------------------------------------------- #
def _generate_portal_token(order_id: str, order_type: str, email: str, ttl_hours: int = 72) -> str:
"""Sign a portal JWT matching the format expected by requirePortalAuth in the API."""
try:
import jwt as _jwt
except ImportError:
import subprocess
subprocess.run(["pip", "install", "PyJWT"], check=True, capture_output=True)
import jwt as _jwt
secret = os.environ.get("CUSTOMER_JWT_SECRET", "changeme_long_random_string")
payload = {
"order_id": order_id,
"order_type": order_type,
"email": email,
"iat": int(datetime.now(timezone.utc).timestamp()),
"exp": int((datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).timestamp()),
}
return _jwt.encode(payload, secret, algorithm="HS256")
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "orders@performancewest.net")
ADMIN_EMAIL = "ops@performancewest.net"
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "performancewest")
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
class CanadaCRTCHandler(BaseServiceHandler):
"""Service handler for Canada CRTC corporation formation orders."""
SERVICE_SLUG = "canada-crtc"
SERVICE_NAME = "Canada CRTC Corporation Formation"
TEMPLATE_NAME = "crtc_notification_letter.docx"
REQUIRES_LLM = False
def __init__(self) -> None:
super().__init__()
# Portal is loaded per-order based on province in process()
self.portal = None
self.prov_config = BC_CONFIG # default, overridden in process()
# ------------------------------------------------------------------ #
# Standard-vs-expedited delay helper
# ------------------------------------------------------------------ #
def _maybe_defer_for_standard(self, order_data: dict, checkpoint: str) -> bool:
"""Insert a 3 business day delay for standard orders. Returns True if deferred.
Expedited orders (`custom_expedited == True`) skip the delay entirely.
Standard orders set defer_until 3 business days out and the pipeline pauses;
the job server's deferred-orders poller will re-dispatch when defer_until passes.
Idempotency: if defer_until is already set in the future for the same checkpoint,
we return True (still deferred); if defer_until has passed, we return False
(resume the pipeline).
"""
# Expedited skips all delays
if order_data.get("custom_expedited", False):
return False
order_number = order_data.get("name", "")
if not order_number:
return False
# Check if we've already deferred and the delay has passed
try:
import psycopg2
from datetime import datetime, timezone
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
cur.execute(
"SELECT defer_until, automation_note FROM canada_crtc_orders WHERE order_number = %s",
(order_number,),
)
row = cur.fetchone()
existing_defer = row[0] if row else None
existing_note = row[1] if row else None
now = datetime.now(timezone.utc)
# If we're already past the defer time AND the note matches this checkpoint,
# the delay is complete — clear it and resume.
if existing_defer and existing_note == checkpoint:
if existing_defer <= now:
LOG.info(
"[%s] Defer for %s complete (was %s) — resuming pipeline",
order_number, checkpoint, existing_defer.isoformat(),
)
cur.execute(
"UPDATE canada_crtc_orders SET defer_until = NULL, automation_note = NULL, "
"automation_status = 'Pending' WHERE order_number = %s",
(order_number,),
)
conn.commit()
conn.close()
return False
else:
# Still waiting
LOG.info(
"[%s] Already deferred at %s until %s — pausing",
order_number, checkpoint, existing_defer.isoformat(),
)
conn.close()
return True
# First time hitting this checkpoint — set the defer
from scripts.workers.business_days import business_days_from_now
defer_until = business_days_from_now(3)
cur.execute(
"UPDATE canada_crtc_orders SET defer_until = %s, automation_note = %s, "
"automation_status = 'Deferred' WHERE order_number = %s",
(defer_until, checkpoint, order_number),
)
conn.commit()
conn.close()
LOG.info(
"[%s] Standard service: deferring at %s for 3 business days until %s",
order_number, checkpoint, defer_until.isoformat(),
)
# Also update ERPNext if available
try:
if hasattr(self, "erp") and self.erp:
self.erp.update_resource("Sales Order", order_number, {
"automation_status": "Deferred",
"automation_note": f"Standard service: 3 business day delay at {checkpoint}",
})
except Exception:
pass
return True
except Exception as exc:
LOG.warning("[%s] Defer check failed (continuing without delay): %s", order_number, exc)
return False
# ------------------------------------------------------------------ #
# Main processing pipeline
# ------------------------------------------------------------------ #
async def process(self, order_data: dict) -> list[str]:
"""Execute the full 8-step Canada CRTC formation pipeline.
Args:
order_data: ERPNext Sales Order / Formation Order dict.
Returns:
List of generated file paths (binder PDF + individual docs).
"""
order_number = order_data.get("name", "UNKNOWN")
LOG.info("=== Canada CRTC pipeline START: %s ===", order_number)
# Determine incorporation province (default BC for backward compat)
province = order_data.get("custom_incorporation_province", "BC") or "BC"
province = province.upper()
prov_name = PROVINCE_NAMES.get(province, province)
try:
self.prov_config = get_config(province)
self.portal = get_adapter(province)
except ValueError:
LOG.warning("Unknown province %s — falling back to BC", province)
province = "BC"
prov_name = "British Columbia"
self.prov_config = BC_CONFIG
from scripts.formation.states.bc.adapter import BCPortal
self.portal = BCPortal()
LOG.info(" Province: %s (%s)", province, prov_name)
generated_files: list[str] = []
work_dir = self._make_work_dir()
# Build FormationOrder from ERPNext data
formation_order = self._build_formation_order(order_data)
# Track state for ERPNext workflow advancement
erp = self._get_erpnext_client()
try:
# ---------------------------------------------------------- #
# Step 1: Setup Anytime Mailbox
# ---------------------------------------------------------- #
existing_mailbox = order_data.get("custom_mailbox_unit_number", "")
if existing_mailbox:
LOG.info("[Step 1/12] Mailbox already set up (unit %s) — skipping", existing_mailbox)
mailbox_unit = existing_mailbox
mailbox_id = ""
else:
LOG.info("[Step 1/12] Setting up Anytime Mailbox for %s", formation_order.entity_name)
self._advance_workflow(erp, order_number, "Start Mailbox Setup")
mailbox_result = await self.portal.setup_mailbox(formation_order)
mailbox_unit = mailbox_result.get("unit_number", "TBD")
mailbox_id = mailbox_result.get("mailbox_id", "")
if not mailbox_result.get("success"):
LOG.warning(
"Mailbox setup returned non-success (may be stub): %s",
mailbox_result.get("message"),
)
LOG.info("Mailbox unit: %s (mailbox_id: %s)", mailbox_unit, mailbox_id)
# ---------------------------------------------------------- #
# Step 2: Name reservation (skip for numbered companies)
# ---------------------------------------------------------- #
company_type = order_data.get("custom_company_type", "numbered")
is_numbered = self._is_numbered_company(
formation_order.entity_name, company_type
)
nr_number = order_data.get("custom_name_reservation_number", "")
if nr_number:
LOG.info("[Step 2/12] Name already reserved (NR# %s) — skipping", nr_number)
elif not is_numbered:
LOG.info("[Step 2/12] Reserving name: %s", formation_order.entity_name)
self._advance_workflow(erp, order_number, "Start Name Reservation")
nr_result = await self.portal.reserve_name(formation_order.entity_name)
nr_number = nr_result.get("nr_number", "")
if not nr_result.get("success"):
LOG.warning(
"Name reservation returned non-success: %s",
nr_result.get("message"),
)
else:
LOG.info("[Step 2/12] Numbered company — skipping name reservation")
# ---------------------------------------------------------- #
# Step 3: DID provisioning (Canadian phone number)
# (No Canadian Presence required — just a Flowroute purchase.
# Can run before incorporation.)
# ---------------------------------------------------------- #
LOG.info("[Step 3/12] Provisioning Canadian DID")
ca_did = order_data.get("custom_ca_did_number", "")
if not ca_did:
try:
from scripts.workers.services.flowroute import provision_bc_did
did_result = provision_bc_did(order_number=order_number)
if did_result["success"]:
ca_did = did_result["did"]
LOG.info(" DID provisioned: %s (%s)", ca_did, did_result.get("rate_center"))
try:
import psycopg2
pg_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
pg_cur = pg_conn.cursor()
pg_cur.execute(
"UPDATE canada_crtc_orders SET ca_did_number=%s, did_provisioned_at=NOW() WHERE order_number=%s",
(ca_did, order_number),
)
pg_conn.commit()
pg_conn.close()
except Exception as pg_err:
LOG.warning("PG DID update failed: %s", pg_err)
# Configure call routing based on customer preference
_routing_type = order_data.get("custom_did_routing_type", "later")
_fwd_number = order_data.get("custom_did_forward_number", "")
_sip_uri = order_data.get("custom_did_sip_uri", "")
_sip_ip = order_data.get("custom_did_sip_ip", "")
if _routing_type != "later":
from scripts.workers.services.flowroute import configure_routing
route_result = configure_routing(
did=ca_did,
routing_type=_routing_type,
forward_number=_fwd_number,
sip_uri=_sip_uri,
sip_ip=_sip_ip,
order_number=order_number,
)
if route_result["success"]:
LOG.info(" DID routing configured: %s", _routing_type)
else:
LOG.warning(" DID routing failed: %s", route_result.get("error"))
self._advance_workflow(erp, order_number, "Phone Ready")
# Email customer their new Canadian DID
self._send_email(
to_email=client_email,
subject=f"Your Canadian phone number is active — {ca_did}",
body=(
f"Hi {client_name},\n\n"
f"A Canadian phone number has been provisioned for your "
f"carrier registration:\n\n"
f" Phone: {ca_did}\n"
f" Area: BC, Canada\n\n"
f"This number will be listed as your regulatory contact "
f"on your CRTC registration. It can also be used for "
f"business communications with Canadian vendors and "
f"customers.\n\n"
f"Order: {order_number}\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
),
)
else:
LOG.warning(" DID provisioning failed: %s", did_result.get("error"))
except Exception as did_err:
LOG.error(" DID provisioning error: %s", did_err)
else:
LOG.info(" DID already provisioned: %s", ca_did)
# ---------------------------------------------------------- #
# DELAY 1 (standard only): 3 business days after DID + mailbox
# before incorporation filing. Expedited skips this delay.
# ---------------------------------------------------------- #
if self._maybe_defer_for_standard(order_data, "post_did_pre_incorporation"):
LOG.info("[%s] Pipeline PAUSED for standard 3-day delay (post_did_pre_incorporation)", order_number)
return generated_files
# ---------------------------------------------------------- #
# Step 4: Provincial Incorporation
# (Must happen BEFORE .ca domain registration because CIRA
# requires Canadian Presence — the corp number satisfies
# CPR category CCO "Canadian Corporation".)
# ---------------------------------------------------------- #
existing_bc_number = (
order_data.get("custom_incorporation_number")
or formation_order.state_filing_number
or ""
)
incorporation_already_done = bool(existing_bc_number)
if incorporation_already_done:
LOG.info("[Step 4/12] Already incorporated: %s# %s — skipping", province, existing_bc_number)
formation_order.state_filing_number = existing_bc_number
if not incorporation_already_done:
LOG.info("[Step 4/12] Filing %s incorporation for %s", province, formation_order.entity_name)
self._advance_workflow(erp, order_number, "Start Incorporation")
filing_result = None
if not incorporation_already_done:
# Create a CA Filing Request in ERPNext → triggers frappe_ca_registry
# Frappe app handles the actual COLIN automation via Playwright.
import json as _json
director_addr = {}
try:
raw_addr = order_data.get("custom_director_address", "")
if raw_addr and isinstance(raw_addr, str) and raw_addr.startswith("{"):
director_addr = _json.loads(raw_addr)
except Exception:
pass
_company_type_map = {
"numbered": "Numbered",
"numbered_tradename": "Numbered + Trade Name",
"named": "Named",
}
filing_data = {
"doctype": "CA Filing Request",
"filing_type": "Named Incorporation" if company_type == "named" else "Numbered Incorporation",
"province": province,
"sales_order": order_number,
"external_order_id": order_number,
"company_type": _company_type_map.get(company_type, "Numbered"),
"name_reservation_number": nr_number or "",
"trade_name": order_data.get("custom_trade_name", ""),
"director_first_name": order_data.get("custom_director_first_name", ""),
"director_middle_name": order_data.get("custom_director_middle_name", ""),
"director_last_name": order_data.get("custom_director_last_name", ""),
"director_address": director_addr.get("street", ""),
"director_address2": director_addr.get("street2", ""),
"director_city": director_addr.get("city", ""),
"director_province": director_addr.get("province", ""),
"director_postal": director_addr.get("postal", ""),
"director_country": director_addr.get("country", "US"),
"office_address": (
order_data.get("custom_mailbox_address", self.prov_config["registered_office"]["street"] + ", " + self.prov_config["registered_office"]["city"] + ", " + province + " " + self.prov_config["registered_office"]["postal_code"])
.split(",")[0].strip()
),
"notification_email": "filings@performancewest.net",
}
try:
ca_filing = erp.create_resource("CA Filing Request", filing_data)
filing_name = ca_filing.get("name", "")
LOG.info(" CA Filing Request created: %s", filing_name)
# Submit → triggers frappe_ca_registry background job
erp.call_method("frappe.client.submit", {
"doc": {"doctype": "CA Filing Request", "name": filing_name},
})
LOG.info(" CA Filing Request submitted: %s — COLIN automation queued", filing_name)
# The pipeline now PAUSES here. The Frappe app processes the
# incorporation in the background. When it completes, it updates
# the CA Filing Request status → triggers on_update → advances
# the Sales Order workflow → fires a webhook → dispatches the
# next pipeline job which re-enters process() with the corp# set.
LOG.info(" Pipeline PAUSED at Step 4 — waiting for COLIN automation to complete")
return generated_files
except Exception as filing_err:
LOG.error(" Failed to create/submit CA Filing Request: %s", filing_err)
# Fall back to the old adapter for now
LOG.warning(" Falling back to old %s adapter", province)
filing_result = await self.portal.file_incorporation(formation_order)
if filing_result and filing_result.success:
formation_order.status = filing_result.status
formation_order.state_filing_number = filing_result.filing_number
formation_order.confirmation_number = filing_result.confirmation_number
formation_order.filed_at = filing_result.timestamp
LOG.info(
"Incorporation filed: %s# %s, confirmation %s", province,
filing_result.filing_number,
filing_result.confirmation_number,
)
# Record payment via Relay
from scripts.workers.relay_integration import record_filing_payment
record_filing_payment(
order_name=order_number,
state_code=province,
amount_cents=int(self.prov_config["fees"]["incorporation"] * 100),
card_last4=formation_order.payment_card_number[-4:]
if formation_order.payment_card_number
else "0000",
confirmation=filing_result.confirmation_number,
)
# Build display name for emails
_inc_company = f"{filing_result.filing_number} {NUMBERED_SUFFIX.get(province, 'B.C. Ltd.')}"
if not is_numbered:
_inc_company = formation_order.entity_name
# Email customer — incorporation complete
self._send_email(
to_email=client_email,
subject=f"Your {prov_name} corporation is registered — {_inc_company}",
body=(
f"Hi {client_name},\n\n"
f"Your {prov_name} corporation has been successfully registered!\n\n"
f" Corporation: {_inc_company}\n"
f" Incorporation Number: {filing_result.filing_number}\n"
f" Registered Office: {formation_order.registered_agent_address}\n"
f" Filed: {filing_result.timestamp or 'Today'}\n"
f" Confirmation: {filing_result.confirmation_number}\n\n"
f"Your Certificate of Incorporation will be delivered to "
f"you as part of your corporate binder.\n\n"
f"Next steps:\n"
f" - We'll email you shortly to choose your .ca domain name\n"
f" - Your CRTC registration letter will be prepared\n"
f" - A Canadian business bank account referral will be sent\n\n"
f"Order: {order_number}\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
),
)
# Email admin
self._send_email(
to_email="ops@performancewest.net",
subject=f"[CRTC] Incorporation complete — {order_number}{province}# {filing_result.filing_number}",
body=(
f"{prov_name} incorporation complete for {order_number}.\n\n"
f"Corporation: {_inc_company}\n"
f"{province}#: {filing_result.filing_number}\n"
f"Confirmation: {filing_result.confirmation_number}\n"
f"Customer: {client_name} ({client_email})\n"
),
)
elif filing_result:
LOG.warning(
"Incorporation returned non-success (may be stub): %s",
filing_result.error_message,
)
# Collect any documents from filing
for doc_path in (filing_result.documents if filing_result else []):
generated_files.append(doc_path)
# ---------------------------------------------------------- #
# Step 5b: Trade name registration (numbered_tradename only)
# ---------------------------------------------------------- #
add_trade_name = order_data.get("custom_add_trade_name", False)
trade_name_value = order_data.get("custom_trade_name", "")
if add_trade_name and trade_name_value and (incorporation_already_done or (filing_result and filing_result.success)):
LOG.info(
"[Step 5b] Filing trade name '%s' for %s# %s",
trade_name_value,
province,
formation_order.state_filing_number,
)
LOG.warning(
"Trade name filing not yet automated — "
"creating admin task for manual filing. "
"Trade name: '%s', %s#: %s, Fee: C$40",
trade_name_value,
province,
formation_order.state_filing_number,
)
try:
erp.create_resource("ToDo", {
"description": (
f"File trade name '{trade_name_value}' for "
f"{province}# {formation_order.state_filing_number} "
f"(Order: {order_number}). "
f"Fee: C$40 via Relay card. "
f"Portal: {self.prov_config['filing_portal']['url']}"
),
"priority": "Medium",
"allocated_to": "Administrator",
"reference_type": "Sales Order",
"reference_name": order_number,
})
except Exception as td_err:
LOG.error("Failed to create trade name ToDo: %s", td_err)
elif add_trade_name and filing_result and not filing_result.success:
LOG.warning("Skipping trade name filing — incorporation did not succeed")
# ---------------------------------------------------------- #
# Step 4c: Add corporation name to Anytime Mailbox
# (AMB was initially set up with the customer's personal name
# in Step 1. Now that we have the corp name, add it as an
# authorized recipient so mail addressed to the corporation
# at the registered office is accepted.)
# ---------------------------------------------------------- #
company_name_final = formation_order.state_filing_number
if (incorporation_already_done or (filing_result and filing_result.success)) and company_name_final:
# For numbered corps: "1234567 B.C. Ltd." or "1234567 Ontario Inc."
if is_numbered and not trade_name_value:
company_name_final = f"{formation_order.state_filing_number} {NUMBERED_SUFFIX.get(province, 'B.C. Ltd.')}"
elif trade_name_value:
company_name_final = trade_name_value
else:
company_name_final = formation_order.entity_name
LOG.info(
"[Step 4c] Adding '%s' as authorized recipient at Anytime Mailbox",
company_name_final,
)
# STUB — waiting on Anytime Mailbox bulk API response.
# For now, create an admin task for manual action.
LOG.warning(
"AMB recipient update not yet automated — creating admin task. "
"Add '%s' as 2nd identity/recipient at the mailbox.",
company_name_final,
)
try:
erp.create_resource("ToDo", {
"description": (
f"Add corporation name to Anytime Mailbox:\n"
f" Company: {company_name_final}\n"
f" Mailbox: {order_data.get('custom_mailbox_address', '329 Howe St, Vancouver')}\n"
f" Order: {order_number}\n\n"
f"Log into the AMB dashboard and add this as a "
f"second recipient / authorized name so mail "
f"addressed to the corporation is accepted."
),
"priority": "High",
"allocated_to": "Administrator",
"reference_type": "Sales Order",
"reference_name": order_number,
})
except Exception as td_err:
LOG.error("Failed to create AMB ToDo: %s", td_err)
# ---------------------------------------------------------- #
# Step 5: Domain selection + registration (.ca)
# (AFTER incorporation — CIRA requires Canadian Presence.
# The corp number satisfies CPR category CCO.)
#
# If customer hasn't chosen a domain yet, send them an email
# with a link to the portal domain search page and PAUSE.
# The pipeline resumes when the customer confirms their choice
# via POST /api/v1/canada-crtc/domain-confirm, which triggers
# the register_ca_domain job.
# ---------------------------------------------------------- #
LOG.info("[Step 5/12] Domain registration (.ca)")
ca_domain = order_data.get("custom_ca_domain", "")
company_display = formation_order.state_filing_number
if is_numbered and not trade_name_value:
company_display = f"{formation_order.state_filing_number} {NUMBERED_SUFFIX.get(province, 'B.C. Ltd.')}"
elif trade_name_value:
company_display = trade_name_value
else:
company_display = formation_order.entity_name
if not ca_domain:
# Customer hasn't chosen a domain yet — send them the portal link
portal_url = (
f"https://performancewest.net/portal/domain-search"
f"?order={order_number}"
)
LOG.info(
" No domain selected — emailing customer portal link: %s",
portal_url,
)
self._send_email(
to_email=client_email,
subject=f"Choose your .ca domain — {company_display}",
body=(
f"Hi {client_name},\n\n"
f"Great news — your {prov_name} corporation ({company_display}) has been "
f"successfully registered!\n\n"
f"The next step is to choose your .ca domain name. This will be "
f"your Canadian carrier identity — used for your CRTC registration "
f"email, business communications, and website.\n\n"
f"Click the link below to search for available .ca domains and "
f"select the one you want:\n\n"
f" {portal_url}\n\n"
f"Tips for choosing a domain:\n"
f" - Keep it short and easy to spell\n"
f" - Your company name or trade name works well\n"
f" - The .ca domain establishes your Canadian presence\n\n"
f"Once you confirm your choice, we'll register it immediately "
f"and set up your email addresses (including regulatory@yourdomain.ca "
f"for CRTC correspondence).\n\n"
f"Order number: {order_number}\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
),
)
self._send_email(
to_email="ops@performancewest.net",
subject=f"[CRTC] Awaiting domain selection — {order_number}",
body=(
f"Order {order_number} is waiting for the customer to select "
f"a .ca domain name.\n\n"
f"Customer: {client_name} ({client_email})\n"
f"Company: {company_display}\n"
f"{province}#: {formation_order.state_filing_number}\n"
f"Portal link sent: {portal_url}\n\n"
f"Pipeline is PAUSED at Step 5. It will resume automatically "
f"when the customer confirms their domain choice via the portal."
),
)
self._advance_workflow(erp, order_number, "Awaiting Domain Selection")
# PAUSE — pipeline stops here. Resumes when customer confirms
# their domain via POST /api/v1/canada-crtc/domain-confirm
LOG.info(" Pipeline PAUSED — waiting for customer domain selection")
return generated_files
# Domain is already set — either customer pre-selected it or
# just confirmed it via the portal. Check if it's registered yet.
domain_already_registered = order_data.get("custom_domain_provisioned_at") is not None
# regulatory@ SMTP password — populated by HestiaCP provisioning below.
# Kept in scope here so Step 7a (binder email) can use it even when
# domain provisioning was done in a previous pipeline run.
regulatory_smtp_password: str = ""
hestia_mailboxes: dict = {}
if domain_already_registered:
LOG.info(" Domain already registered: %s — skipping to next step", ca_domain)
else:
LOG.info(" Domain selected: %s — registering via Porkbun", ca_domain)
if not domain_already_registered:
from scripts.workers.services.porkbun import register_ca_domain
bc_number = formation_order.state_filing_number or ""
_domain_privacy = order_data.get("custom_domain_privacy", True)
_customer_email = order_data.get("custom_customer_email", client_email or "")
domain_result = register_ca_domain(
ca_domain,
order_number=order_number,
domain_privacy=_domain_privacy,
customer_email=_customer_email,
)
if domain_result["success"]:
LOG.info(" Domain registered: %s (%s# %s for CIRA CPR)", ca_domain, province, bc_number)
try:
import psycopg2
pg_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
pg_cur = pg_conn.cursor()
pg_cur.execute(
"UPDATE canada_crtc_orders SET domain_provisioned_at=NOW() WHERE order_number=%s",
(order_number,),
)
pg_conn.commit()
pg_conn.close()
except Exception as pg_err:
LOG.warning("PG domain update failed: %s", pg_err)
else:
LOG.warning(" Domain registration failed: %s", domain_result.get("error"))
ca_domain = ""
# Step 5b: Provision hosting + email on HestiaCP
# This creates regulatory@domain.ca (and 13 other mailboxes).
# Credentials are stored in an ERPNext Sensitive ID document and
# emailed to the client in a separate secure message.
#
# Pipeline note:
# CIRA registrant = filings@performancewest.net (Porkbun account
# owner — cannot be changed via API). The client's Canadian identity
# is established through regulatory@domain.ca on the CRTC letter.
if ca_domain:
try:
from scripts.workers.hestia_provisioner import HestiaProvisioner
provisioner = HestiaProvisioner()
hestia_result = await provisioner.provision_domain(
domain=ca_domain,
client_name=company_display,
order_number=order_number,
)
if hestia_result.get("success"):
LOG.info(" HestiaCP provisioning complete for %s", ca_domain)
hestia_mailboxes = hestia_result.get("mailboxes", {})
regulatory_smtp_password = hestia_result.get("regulatory_password", "")
self._advance_workflow(erp, order_number, "Domain Ready")
# Store all credentials in ERPNext Sensitive ID (encrypted)
so_name = order_data.get("name", order_number)
await provisioner.store_credentials_to_erpnext(
erp=erp,
order_number=order_number,
domain=ca_domain,
mailboxes=hestia_mailboxes,
sales_order_name=so_name,
)
# ── Email 1: Domain live notification (brief) ────────────
self._send_email(
to_email=client_email,
subject=f"Your .ca domain and email are live — {ca_domain}",
body=(
f"Hi {client_name},\n\n"
f"Your Canadian domain {ca_domain} has been registered "
f"for {company_display} and all email addresses are "
f"now active on our Carbonio mail server.\n\n"
f"Webmail: https://co.carrierone.com:6071\n"
f"IMAP: co.carrierone.com:993 (SSL)\n"
f"SMTP: co.carrierone.com:587 (STARTTLS)\n\n"
f"Your complete email credentials — including passwords "
f"for all 14 mailboxes — will arrive in a separate "
f"secure email within the next few minutes.\n\n"
f"We are now proceeding with your CRTC registration "
f"letter. You will receive it for eSignature shortly.\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
),
)
# ── Email 2: Full credentials (secure separate message) ──
if hestia_mailboxes:
cred_lines = []
for prefix, cred in hestia_mailboxes.items():
cred_lines.append(
f"{''*50}\n"
f" {cred['email']}\n"
f" Password: {cred['password']}\n"
f" Purpose: {cred['description']}\n"
)
cred_block = "\n".join(cred_lines)
self._send_email(
to_email=client_email,
subject=f"Your email credentials — {ca_domain} [CONFIDENTIAL]",
body=(
f"Hi {client_name},\n\n"
f"Below are the login credentials for all email accounts "
f"created on your domain {ca_domain}.\n\n"
f"{''*50}\n"
f"MAIL SERVER SETTINGS (same for all accounts)\n"
f"{''*50}\n"
f" Webmail: https://co.carrierone.com:6071\n"
f" IMAP host: co.carrierone.com port: 993 SSL: Yes\n"
f" SMTP host: co.carrierone.com port: 587 TLS: Yes\n\n"
f"{''*50}\n"
f"EMAIL ACCOUNTS\n"
f"{''*50}\n"
f"{cred_block}\n"
f"IMPORTANT:\n"
f" - regulatory@{ca_domain} is your primary CRTC contact "
f"address — it will appear on your CRTC registration letter\n"
f" - abuse@ and postmaster@ are RFC 2142 required for "
f"Canadian telecom carriers\n"
f" - Please change these passwords after your first login\n\n"
f"Please store these credentials securely and do not "
f"share them by email after this initial delivery.\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
),
)
else:
LOG.warning(
" HestiaCP provisioning failed: %s",
hestia_result.get("errors", hestia_result),
)
except Exception as hestia_err:
LOG.error(" HestiaCP provisioning error: %s", hestia_err)
# ---------------------------------------------------------- #
# DELAY 2 (standard only): 3 business days after email
# provisioning, before CRTC letter generation. Expedited skips.
# ---------------------------------------------------------- #
if self._maybe_defer_for_standard(order_data, "post_email_pre_letter"):
LOG.info("[%s] Pipeline PAUSED for standard 3-day delay (post_email_pre_letter)", order_number)
return generated_files
# ---------------------------------------------------------- #
# Step 6: Generate CRTC notification letter
# ---------------------------------------------------------- #
LOG.info("[Step 6/12] Generating CRTC notification letter")
self._advance_workflow(erp, order_number, "Generate CRTC Letter")
# Set the regulatory contact to the provisioned Canadian identity:
# Name: "Regulatory Director" (generic title)
# Phone: The Canadian DID we just provisioned
# Email: regulatory@{.ca domain}
# Falls back to the customer-provided contact if provisioning failed.
if ca_domain:
formation_order.regulatory_contact_email = f"regulatory@{ca_domain}"
if ca_did:
formation_order.regulatory_contact_phone = ca_did
formation_order.regulatory_contact_name = "Regulatory Director"
LOG.info(
" Regulatory contact: %s | %s | %s",
formation_order.regulatory_contact_name,
formation_order.regulatory_contact_phone,
formation_order.regulatory_contact_email,
)
# Generate CRTC letter using the new template generator
from scripts.document_gen.templates.crtc_letter_generator import generate_crtc_letter as _gen_crtc
crtc_docx_path = _gen_crtc(
entity_name=formation_order.entity_name or f"{formation_order.state_filing_number} B.C. Ltd.",
incorporation_number=formation_order.state_filing_number or "",
registered_office=formation_order.registered_agent_address,
services_description=order_data.get("custom_services_description", ""),
geographic_coverage=order_data.get("custom_geographic_coverage", f"{province} and Worldwide"),
include_bits=order_data.get("custom_include_bits", True),
regulatory_contact_name=formation_order.regulatory_contact_name,
regulatory_contact_email=formation_order.regulatory_contact_email,
regulatory_contact_phone=formation_order.regulatory_contact_phone,
director_name=order_data.get("custom_director_name", ""),
ca_domain=ca_domain,
output_path=os.path.join(work_dir, f"crtc_notification_letter_{order_number}.docx"),
)
if crtc_docx_path:
generated_files.append(crtc_docx_path)
LOG.info("CRTC letter generated: %s", crtc_docx_path)
else:
LOG.warning("CRTC letter generation failed — will need manual creation")
# ---------------------------------------------------------- #
# Step 6b: eSign pause
# Upload the CRTC letter PDF to MinIO, store the object key on
# the order, advance workflow to "Pending eSign", email the client
# a JWT-signed portal link to sign the letter.
# Pipeline resumes when the client submits their signature via
# POST /api/v1/portal/esign-submit → resume_crtc_pipeline job.
# ---------------------------------------------------------- #
esign_already_done = bool(order_data.get("custom_esign_signed_at"))
if not esign_already_done and crtc_docx_path:
# Convert letter to PDF for preview
try:
from scripts.document_gen.pdf_converter import convert_to_pdf as _to_pdf
crtc_pdf_path = _to_pdf(crtc_docx_path, work_dir)
except Exception as _pdf_err:
LOG.warning("CRTC letter PDF conversion failed (%s) — using DOCX preview", _pdf_err)
crtc_pdf_path = None
# Upload letter PDF to MinIO
letter_minio_key = ""
if crtc_pdf_path and Path(crtc_pdf_path).exists():
try:
_mc = self._get_minio_client()
if not _mc.bucket_exists(MINIO_BUCKET):
_mc.make_bucket(MINIO_BUCKET)
letter_minio_key = (
f"canada-crtc/{order_number}/"
f"crtc_notification_letter_{order_number}.pdf"
)
_mc.fput_object(MINIO_BUCKET, letter_minio_key, str(crtc_pdf_path))
LOG.info("CRTC letter uploaded to MinIO: %s", letter_minio_key)
except Exception as _minio_err:
LOG.warning("Failed to upload letter to MinIO (%s) — sign page will show no preview", _minio_err)
letter_minio_key = ""
# Store the MinIO key on the PG order so the sign page can fetch it
if letter_minio_key:
try:
import psycopg2 as _pg
_conn = _pg.connect(os.environ.get("DATABASE_URL", ""))
with _conn.cursor() as _cur:
_cur.execute(
"UPDATE canada_crtc_orders SET crtc_letter_minio_key = %s WHERE order_number = %s",
(letter_minio_key, order_number),
)
_conn.commit()
_conn.close()
except Exception as _pg_err:
LOG.warning("Failed to store letter MinIO key in PG: %s", _pg_err)
# Also update ERPNext SO with the MinIO key
try:
erp.update_resource("Sales Order", order_number, {
"custom_crtc_letter_minio_key": letter_minio_key,
})
except Exception:
pass
# Advance workflow to "Pending eSign"
self._advance_workflow(erp, order_number, "Awaiting eSign")
# Build a signed portal link (72h JWT)
_portal_token = _generate_portal_token(
order_id=order_number,
order_type="canada_crtc",
email=client_email or "",
)
sign_url = f"https://performancewest.net/portal/sign?token={_portal_token}"
# Email client the sign link
self._send_email(
to_email=client_email,
subject=f"ACTION REQUIRED — Sign your CRTC registration letter — {company_display}",
body=(
f"Hi {client_name},\n\n"
f"Your CRTC notification letter is ready for your eSignature.\n\n"
f"Please click the link below to review and sign the letter:\n\n"
f" {sign_url}\n\n"
f"This link is valid for 72 hours. You will need to:\n"
f" 1. Review the full CRTC notification letter\n"
f" 2. Draw your signature\n"
f" 3. Confirm and submit\n\n"
f"Your CRTC registration cannot proceed until the letter is signed.\n\n"
f"If you have any questions, reply to this email.\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
f"https://performancewest.net\n"
),
)
LOG.info(
"[Step 6b] eSign link sent to %s — pipeline PAUSED pending signature",
client_email,
)
return generated_files # Pipeline pauses here
elif not esign_already_done:
LOG.warning("[Step 6b] No CRTC letter path — skipping eSign pause")
LOG.info("[Step 6b] eSign already done (signed_at=%s) — continuing",
order_data.get("custom_esign_signed_at"))
# ---------------------------------------------------------- #
# Step 7: Compile corporate binder (idempotent — skips if already done)
# ---------------------------------------------------------- #
binder_already_compiled = bool(order_data.get("custom_binder_compiled_at"))
binder_path = None
if binder_already_compiled:
LOG.info("[Step 7/12] Binder already compiled — skipping")
else:
LOG.info("[Step 7/12] Compiling corporate binder")
from scripts.workers.binder_compiler import BinderCompiler
compiler = BinderCompiler()
binder_path = compiler.compile(
entity_name=formation_order.entity_name,
incorporation_number=formation_order.state_filing_number or "PENDING",
order_number=order_number,
pdf_paths=generated_files,
output_dir=work_dir,
)
if binder_path:
LOG.info("Corporate binder compiled: %s", binder_path)
try:
import psycopg2
_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with _conn.cursor() as _cur:
_cur.execute(
"UPDATE canada_crtc_orders SET binder_compiled_at = NOW() WHERE order_number = %s",
(order_number,),
)
_conn.commit()
_conn.close()
except Exception:
pass
else:
LOG.warning("Binder compilation failed — individual files will be delivered")
binder_path = None
# ---------------------------------------------------------- #
# Step 8: Upload binder to MinIO (idempotent)
# ---------------------------------------------------------- #
binder_already_uploaded = bool(order_data.get("custom_binder_uploaded_at"))
minio_urls = []
if binder_already_uploaded:
LOG.info("[Step 8/12] Binder already uploaded to MinIO — skipping")
else:
LOG.info("[Step 8/12] Uploading binder to MinIO")
minio_client = self._get_minio_client()
if not minio_client.bucket_exists(MINIO_BUCKET):
minio_client.make_bucket(MINIO_BUCKET)
# Upload binder
if binder_path and Path(binder_path).exists():
binder_object_name = f"canada-crtc/{order_number}/corporate-binder-{order_number}.pdf"
minio_client.fput_object(MINIO_BUCKET, binder_object_name, binder_path)
minio_urls.append(f"minio://{MINIO_BUCKET}/{binder_object_name}")
LOG.info("Uploaded binder: %s", binder_object_name)
# Upload individual files
for fpath in generated_files:
if Path(fpath).exists():
obj_name = f"canada-crtc/{order_number}/{Path(fpath).name}"
minio_client.fput_object(MINIO_BUCKET, obj_name, fpath)
minio_urls.append(f"minio://{MINIO_BUCKET}/{obj_name}")
# Update ERPNext with file URLs
if minio_urls:
erp.update_resource("Sales Order", order_number, {
"custom_generated_files": "\n".join(minio_urls),
})
try:
import psycopg2
_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with _conn.cursor() as _cur:
_cur.execute(
"UPDATE canada_crtc_orders SET binder_uploaded_at = NOW() WHERE order_number = %s",
(order_number,),
)
_conn.commit()
_conn.close()
except Exception:
pass
# ---------------------------------------------------------- #
# Step 9: Email binder to client + print instructions to admin
# (idempotent — won't re-send if binder_emailed_at is set)
# ---------------------------------------------------------- #
binder_already_emailed = bool(order_data.get("custom_binder_emailed_at"))
client_email = order_data.get("custom_client_email") or order_data.get("customer_email", "")
client_name = order_data.get("customer_name", formation_order.entity_name)
if binder_already_emailed:
LOG.info("[Step 9/12] Binder already emailed — skipping")
elif not client_email:
LOG.warning("[Step 9/12] No client email — skipping binder delivery")
else:
LOG.info("[Step 9/12] Sending emails")
# 9a: Email binder to client
# The binder email and CRTC letter are sent FROM regulatory@domain.ca
# so the client's Canadian corporate identity is the sender.
# Falls back to PW Carbonio (SMTP_FROM) if regulatory@ wasn't provisioned.
has_own_ca = bool(order_data.get("custom_has_own_ca_address"))
own_registered_office = order_data.get("custom_mailbox_address", "") if has_own_ca else ""
regulatory_from = f"regulatory@{ca_domain}" if ca_domain else None
regulatory_pw = regulatory_smtp_password or None
if binder_path:
self._send_client_email(
to_email=client_email,
client_name=client_name,
entity_name=formation_order.entity_name,
order_number=order_number,
binder_path=binder_path,
mailbox_unit=mailbox_unit,
ca_did=ca_did,
ca_domain=ca_domain,
has_own_ca=has_own_ca,
own_registered_office=own_registered_office,
from_addr=regulatory_from,
from_password=regulatory_pw,
)
# 9b: Email print/ship instructions to admin
binder_company = order_data.get("custom_own_ca_company") or ""
binder_attn = order_data.get("custom_own_ca_attn") or ""
if binder_path:
self._send_admin_print_email(
entity_name=formation_order.entity_name,
order_number=order_number,
mailbox_unit=mailbox_unit,
binder_path=binder_path,
has_own_ca=has_own_ca,
binder_company=binder_company,
binder_attn=binder_attn,
mailbox_address=order_data.get("custom_mailbox_address", ""),
)
try:
import psycopg2
_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
with _conn.cursor() as _cur:
_cur.execute(
"UPDATE canada_crtc_orders SET binder_emailed_at = NOW() WHERE order_number = %s",
(order_number,),
)
_conn.commit()
_conn.close()
except Exception:
pass
# ---------------------------------------------------------- #
# DELAY 3 (standard only): 3 business days after client + admin
# binder delivery, before BITS/CCTS final compliance steps.
# Expedited skips this delay.
# ---------------------------------------------------------- #
if self._maybe_defer_for_standard(order_data, "post_delivery_pre_compliance"):
LOG.info("[%s] Pipeline PAUSED for standard 3-day delay (post_delivery_pre_compliance)", order_number)
return generated_files
# ---------------------------------------------------------- #
# Step 10: Send banking referral email
# ---------------------------------------------------------- #
LOG.info("[Step 10/12] Sending banking referral email")
VENN_REFERRAL_URL = os.environ.get(
"VENN_REFERRAL_URL",
"https://app.venn.ca/signup?referral=4u6qvwnf&utm_source=app&utm_campaign=referral",
)
if client_email:
try:
self._send_banking_referral_email(
to_email=client_email,
client_name=client_name,
entity_name=formation_order.entity_name,
referral_url=VENN_REFERRAL_URL,
)
# Update PG
import psycopg2
pg_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
pg_cur = pg_conn.cursor()
pg_cur.execute(
"UPDATE canada_crtc_orders SET banking_referral_sent_at=NOW() WHERE order_number=%s",
(order_number,),
)
pg_conn.commit()
pg_conn.close()
self._advance_workflow(erp, order_number, "Banking Ready")
except Exception as bank_err:
LOG.warning("Banking referral email failed: %s", bank_err)
# ---------------------------------------------------------- #
# Step 11: BITS registration (CRTC notification)
# ---------------------------------------------------------- #
LOG.info("[Step 11/14] BITS registration — CRTC notification")
self._advance_workflow(erp, order_number, "Start CRTC Registration")
try:
self._register_bits(
erp=erp,
order_number=order_number,
entity_name=formation_order.entity_name,
ca_domain=ca_domain,
)
self._advance_workflow(erp, order_number, "CRTC Registration Ready")
except Exception as bits_err:
LOG.warning("BITS registration step failed: %s", bits_err)
# ---------------------------------------------------------- #
# Step 12: CCTS membership registration
# ---------------------------------------------------------- #
LOG.info("[Step 12/14] CCTS membership registration")
self._advance_workflow(erp, order_number, "Start CCTS Registration")
try:
self._register_ccts(
erp=erp,
order_number=order_number,
entity_name=formation_order.entity_name,
client_email=client_email,
ca_domain=ca_domain,
)
self._advance_workflow(erp, order_number, "CCTS Complete")
except Exception as ccts_err:
LOG.warning("CCTS registration step failed: %s", ccts_err)
# ---------------------------------------------------------- #
# Step 13: Create compliance calendar entries
# ---------------------------------------------------------- #
LOG.info("[Step 13/14] Creating compliance calendar entries")
self._create_compliance_entries(
erp=erp,
order_number=order_number,
entity_name=formation_order.entity_name,
mailbox_unit=mailbox_unit,
ca_domain=ca_domain,
)
# ---------------------------------------------------------- #
# Step 14: Advance to final review
# ---------------------------------------------------------- #
self._advance_workflow(erp, order_number, "Ready for Review")
LOG.info("=== Canada CRTC pipeline COMPLETE: %s ===", order_number)
# Return all generated file paths
all_files = list(generated_files)
if binder_path:
all_files.append(binder_path)
return all_files
except Exception as exc:
LOG.exception("Canada CRTC pipeline failed for %s: %s", order_number, exc)
# Create failure issue in ERPNext
try:
erp.create_issue(
subject=f"Canada CRTC pipeline failed: {order_number}",
description=(
f"Order **{order_number}** ({formation_order.entity_name}) "
f"failed during Canada CRTC formation pipeline.\n\n"
f"```\n{exc}\n```"
),
priority="High",
)
except Exception:
LOG.exception("Failed to create ERPNext issue for %s", order_number)
raise
# ------------------------------------------------------------------ #
# Order construction
# ------------------------------------------------------------------ #
def _build_formation_order(self, order_data: dict) -> FormationOrder:
"""Construct a FormationOrder from ERPNext order data."""
members = []
directors = order_data.get("custom_directors") or []
for director in directors:
members.append(Member(
name=director.get("name", ""),
address=director.get("address", ""),
city=director.get("city", ""),
state=director.get("province", province),
zip_code=director.get("postal_code", ""),
title="Director",
is_organizer=director.get("is_incorporator", False),
))
# Use per-order mailbox address if available, else fall back to config default
office = self.prov_config["registered_office"]
order_mailbox_address = order_data.get("custom_mailbox_address", "")
if order_mailbox_address:
# Parse "329 Howe St, Vancouver, BC V6C 3N2" into components
import re
parts = [p.strip() for p in order_mailbox_address.split(",")]
_street = parts[0] if len(parts) > 0 else office["street"]
_city = parts[1] if len(parts) > 1 else office["city"]
# Last part may be "BC V6C 3N2"
_province_postal = parts[2] if len(parts) > 2 else f"{office['province']} {office['postal_code']}"
_pp = _province_postal.strip().split(" ", 1)
_province = _pp[0] if _pp else office["province"]
_postal = _pp[1] if len(_pp) > 1 else office["postal_code"]
else:
_street = office["street"]
_city = office["city"]
_province = office["province"]
_postal = office["postal_code"]
return FormationOrder(
order_id=order_data.get("name", ""),
state_code=order_data.get("custom_incorporation_province", "BC") or "BC",
entity_type=EntityType.CORPORATION,
entity_name=order_data.get("custom_entity_name", ""),
entity_name_alt=order_data.get("custom_entity_name_alt", ""),
management_type="board_managed",
purpose=order_data.get("custom_purpose", "Any lawful business activity"),
members=members,
registered_agent_name="Anytime Mailbox",
registered_agent_address=f"{_street}, {_city}, {_province} {_postal}",
principal_address=order_data.get("custom_principal_address", _street),
principal_city=order_data.get("custom_principal_city", _city),
principal_state=order_data.get("custom_principal_province", _province),
principal_zip=order_data.get("custom_principal_postal", _postal),
mailing_address=_street,
mailing_city=_city,
mailing_state=_province,
mailing_zip=_postal,
shares_authorized=order_data.get("custom_shares_authorized", 1000),
par_value=0.0,
expedited=order_data.get("custom_expedited", False),
)
@staticmethod
def _is_numbered_company(entity_name: str, company_type: str = "") -> bool:
"""Check if this is a numbered company (no name reservation needed).
Both 'numbered' and 'numbered_tradename' skip name reservation —
the trade name is filed separately after incorporation.
"""
if company_type in ("numbered", "numbered_tradename"):
return True
if not entity_name:
return True
name_lower = entity_name.strip().lower()
return (
name_lower.startswith("numbered")
or name_lower == ""
or "numbered company" in name_lower
or "numbered corp" in name_lower
)
# ------------------------------------------------------------------ #
# Individual step methods (called by job server handlers)
# ------------------------------------------------------------------ #
async def generate_documents(self, order_data: dict) -> list[str]:
"""Generate CRTC letter + corporate binder for an existing order.
Called by the `generate_crtc_docs` job handler after incorporation
completes. This runs Steps 6-8 (CRTC letter, binder, MinIO upload)
as a standalone operation.
"""
order_number = order_data.get("name", "")
LOG.info("[generate_documents] %s", order_number)
erp = self._get_erpnext_client()
formation_order = self._build_formation_order(order_data)
work_dir = Path(f"/tmp/crtc-docs-{order_number}")
work_dir.mkdir(parents=True, exist_ok=True)
generated_files: list[str] = []
# CRTC letter
self._advance_workflow(erp, order_number, "Generate CRTC Letter")
crtc_pdf_path = await self.portal.generate_crtc_letter(formation_order)
if crtc_pdf_path:
generated_files.append(crtc_pdf_path)
# Binder
from scripts.workers.binder_compiler import BinderCompiler
compiler = BinderCompiler()
binder_path = compiler.compile(
entity_name=formation_order.entity_name,
incorporation_number=formation_order.state_filing_number or "PENDING",
order_number=order_number,
pdf_paths=generated_files,
output_dir=str(work_dir),
)
if binder_path:
generated_files.append(binder_path)
# Upload to MinIO
minio_client = self._get_minio_client()
if not minio_client.bucket_exists(MINIO_BUCKET):
minio_client.make_bucket(MINIO_BUCKET)
for fp in generated_files:
p = Path(fp)
remote = f"canada-crtc/{order_number}/{p.name}"
minio_client.fput_object(MINIO_BUCKET, remote, str(p))
self._advance_workflow(erp, order_number, "Ready for Review")
return generated_files
async def ship_binder(self, order_data: dict) -> dict:
"""Send shipping notification emails for a CRTC order.
Called by the `ship_binder` job handler after admin approves.
Sends admin email with PirateShip instructions + client email
with tracking information.
"""
order_number = order_data.get("name", "")
LOG.info("[ship_binder] %s", order_number)
erp = self._get_erpnext_client()
formation_order = self._build_formation_order(order_data)
# The actual shipping email logic is in Steps 9-10 of process().
# For now, advance the workflow and let the ERPNext Email Notification
# handle the client/admin emails.
self._advance_workflow(erp, order_number, "Ship Binder")
return {"order": order_number, "status": "shipping_initiated"}
# ------------------------------------------------------------------ #
# BITS / CCTS registration helpers
# ------------------------------------------------------------------ #
def _register_bits(
self,
erp,
order_number: str,
entity_name: str,
ca_domain: str = "",
) -> None:
"""Register the carrier with CRTC under BITS regime.
The CRTC notification letter (already generated and eSigned in Step 6)
serves as the BITS registration. This step:
1. Creates an ERPNext ToDo for admin to confirm the letter was
received and acknowledged by the CRTC Secretary General.
2. Updates the Sales Order with the BITS filing timestamp.
3. Sends admin a notification email with tracking instructions.
Full automation of CRTC acknowledgement tracking is not possible —
the CRTC responds by mail or email within 30-60 days.
"""
LOG.info("BITS registration for %s (order %s)", entity_name, order_number)
bits_config = self.prov_config.get("bits", {})
crtc_config = self.prov_config.get("crtc", {})
ats_config = self.prov_config.get("ats", {})
# ── Sub-step A: Create GCKey account for the carrier ───────────
gckey_creds = None
try:
from scripts.workers.gckey_provisioner import (
GCKeyProvisioner, store_gckey_credentials,
)
recovery_email = f"regulatory@{ca_domain}" if ca_domain else ""
# Extract BC number from order data
bc_number = ""
try:
import psycopg2
pg_conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
pg_cur = pg_conn.cursor()
pg_cur.execute(
"SELECT incorporation_number FROM canada_crtc_orders WHERE order_number=%s",
(order_number,),
)
row = pg_cur.fetchone()
if row and row[0]:
bc_number = row[0]
pg_conn.close()
except Exception:
pass
if bc_number and recovery_email:
provisioner = GCKeyProvisioner()
gckey_creds = await_or_run(
provisioner.provision(bc_number, recovery_email, entity_name)
)
if gckey_creds and gckey_creds.created:
await_or_run(
store_gckey_credentials(erp, order_number, gckey_creds)
)
LOG.info("GCKey account created: %s", gckey_creds.username)
else:
error_msg = gckey_creds.error if gckey_creds else "unknown"
LOG.warning("GCKey auto-provisioning failed: %s — creating manual ToDo", error_msg)
else:
LOG.info("Skipping GCKey auto-provision (missing bc_number or domain)")
except Exception as gckey_exc:
LOG.warning("GCKey provisioning error: %s — falling back to manual ToDo", gckey_exc)
# ── Sub-step B: Create admin ToDo for CRTC acknowledgement + GCKey ─
reg_line = f"\nRegulatory email: regulatory@{ca_domain}" if ca_domain else ""
gckey_status = ""
if gckey_creds and gckey_creds.created:
gckey_status = (
f"\n\n**GCKey Account (auto-created):**\n"
f"- Username: {gckey_creds.username}\n"
f"- Recovery email: {gckey_creds.recovery_email}\n"
f"- Credentials stored in ERPNext Sensitive ID\n"
f"- Next: request CRTC activation code (call {ats_config.get('activation_code_phone', '1-877-249-2782')})\n"
f"- Then: link GCKey to My CRTC Account for electronic filings"
)
else:
gckey_status = (
f"\n\n**GCKey Account (manual setup needed):**\n"
f"1. Go to https://www.gckey.gc.ca → Sign Up\n"
f"2. Create username: pw-{{bc_number}}\n"
f"3. Set recovery email: regulatory@{ca_domain}\n" if ca_domain else ""
f"4. Store credentials in ERPNext Sensitive ID\n"
f"5. Request CRTC activation code (call {ats_config.get('activation_code_phone', '1-877-249-2782')})\n"
f"6. Link GCKey to My CRTC Account"
)
todo_description = (
f"**BITS Registration — {entity_name}** (Order: {order_number})\n\n"
f"The CRTC notification letter has been sent to the Secretary General "
f"via the signed letter included in the corporate binder.\n\n"
f"**Action required:**\n"
f"1. Confirm the binder was shipped to the CRTC address:\n"
f" {crtc_config.get('secretary_general', 'Secretary General, CRTC')}\n"
f" {crtc_config.get('address', '')}, "
f"{crtc_config.get('city', '')}, {crtc_config.get('province', '')} "
f"{crtc_config.get('postal_code', '')}\n"
f"2. Monitor for CRTC acknowledgement letter (30-60 days)\n"
f"3. File acknowledgement in ERPNext Sensitive ID when received"
f"{reg_line}"
f"{gckey_status}"
)
try:
erp.create_resource("ToDo", {
"doctype": "ToDo",
"description": todo_description,
"reference_type": "Sales Order",
"reference_name": order_number,
"priority": "Medium",
"status": "Open",
"allocated_to": "Administrator",
})
LOG.info("Created BITS tracking ToDo for %s", order_number)
except Exception as exc:
LOG.warning("Failed to create BITS ToDo: %s", exc)
# Update Sales Order with BITS filing timestamp
try:
erp.update_resource("Sales Order", order_number, {
"custom_bits_filed_at": datetime.utcnow().isoformat(),
})
except Exception as exc:
LOG.warning("Failed to update BITS timestamp on SO: %s", exc)
LOG.info("BITS registration step complete for %s", entity_name)
def _register_ccts(
self,
erp,
order_number: str,
entity_name: str,
client_email: str = "",
ca_domain: str = "",
) -> None:
"""Register the carrier as a CCTS member.
The CCTS (Commission for Complaints for Telecom-television Services)
requires all Canadian telecom providers to be members. Registration is
done online at ccts-cprst.ca.
This step:
1. Creates an ERPNext ToDo for admin to submit the CCTS membership
application on behalf of the client.
2. Sends the client an email explaining their CCTS obligations.
3. Updates the Sales Order with the CCTS registration timestamp.
The CCTS application requires:
- Company legal name and trade name
- Business address (registered office)
- Contact info (regulatory@ email)
- Description of telecom services offered
- CRTC notification letter reference
Full automation via Playwright is planned for a future release.
"""
LOG.info("CCTS registration for %s (order %s)", entity_name, order_number)
ccts_config = self.prov_config.get("ccts", {})
ccts_url = ccts_config.get("membership_url", "https://www.ccts-cprst.ca/for-service-providers/become-a-member/")
# Create admin ToDo for CCTS membership application
contact_line = f"- Contact email: regulatory@{ca_domain}\n" if ca_domain else ""
todo_description = (
f"**CCTS Membership Application — {entity_name}** (Order: {order_number})\n\n"
f"Submit CCTS membership application at:\n"
f"{ccts_url}\n\n"
f"**Required information:**\n"
f"- Legal name: {entity_name}\n"
f"{contact_line}"
f"- Client email: {client_email}\n"
f"- Services: Telecommunications services (resale/VoIP)\n"
f"- CRTC notification letter reference: see binder\n\n"
f"**After submission:**\n"
f"1. Save the CCTS membership confirmation/number\n"
f"2. Store in ERPNext Sensitive ID for this order\n"
f"3. Notify client of their CCTS membership number\n"
)
try:
erp.create_resource("ToDo", {
"doctype": "ToDo",
"description": todo_description,
"reference_type": "Sales Order",
"reference_name": order_number,
"priority": "Medium",
"status": "Open",
"allocated_to": "Administrator",
})
LOG.info("Created CCTS membership ToDo for %s", order_number)
except Exception as exc:
LOG.warning("Failed to create CCTS ToDo: %s", exc)
# Send client CCTS obligations email
if client_email:
try:
self._send_email(
to_email=client_email,
subject=f"CCTS Membership — {entity_name}",
html_body=self._render_ccts_notification_email(
entity_name=entity_name,
ca_domain=ca_domain,
),
)
LOG.info("Sent CCTS obligations email to %s", client_email)
except Exception as exc:
LOG.warning("Failed to send CCTS email: %s", exc)
# Update Sales Order with CCTS registration timestamp
try:
erp.update_resource("Sales Order", order_number, {
"custom_ccts_filed_at": datetime.utcnow().isoformat(),
})
except Exception as exc:
LOG.warning("Failed to update CCTS timestamp on SO: %s", exc)
LOG.info("CCTS registration step complete for %s", entity_name)
@staticmethod
def _render_ccts_notification_email(entity_name: str, ca_domain: str = "") -> str:
"""Render the CCTS membership notification email for the client."""
regulatory_email = f"regulatory@{ca_domain}" if ca_domain else "your regulatory contact"
return f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e3a5f;">CCTS Membership — {entity_name}</h2>
<p>As part of your Canadian telecom carrier registration, we have submitted
a membership application with the <strong>Commission for Complaints for
Telecom-television Services (CCTS)</strong> on behalf of {entity_name}.</p>
<h3 style="color: #1e3a5f;">What is the CCTS?</h3>
<p>The CCTS is Canada's independent organization that resolves complaints
between consumers and their telecom/TV service providers. All Canadian
telecom carriers are required to participate.</p>
<h3 style="color: #1e3a5f;">Your Obligations</h3>
<ul>
<li>Respond to any CCTS complaint inquiries within the required timeframes</li>
<li>Maintain accurate contact information with the CCTS</li>
<li>Include CCTS contact information in your terms of service</li>
<li>Complaints should be directed to: <strong>{regulatory_email}</strong></li>
</ul>
<h3 style="color: #1e3a5f;">CCTS Contact Info for Your Records</h3>
<p>
Commission for Complaints for Telecom-television Services<br>
P.O. Box 81088<br>
Ottawa, ON K1P 1B1<br>
Website: <a href="https://www.ccts-cprst.ca">www.ccts-cprst.ca</a><br>
Phone: 1-888-221-1687
</p>
<p>We will notify you once your CCTS membership is confirmed. You will
receive a membership number that should be kept on file.</p>
<p style="color: #666; font-size: 0.9em; margin-top: 30px;">
Performance West Inc. — Canadian Telecom Carrier Registration Services
</p>
</div>
"""
# ------------------------------------------------------------------ #
# ERPNext helpers
# ------------------------------------------------------------------ #
def _get_erpnext_client(self):
"""Return an ERPNext client instance."""
from scripts.workers.erpnext_client import ERPNextClient
return ERPNextClient()
def _get_minio_client(self) -> Minio:
"""Return a MinIO client instance."""
return Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_SECURE,
)
@staticmethod
def _advance_workflow(erp, order_number: str, action: str) -> None:
"""Safely advance the ERPNext workflow state."""
try:
erp.apply_workflow("Sales Order", order_number, action)
except Exception as exc:
LOG.warning("Workflow advance '%s' failed for %s: %s", action, order_number, exc)
def _create_compliance_entries(
self,
erp,
order_number: str,
entity_name: str,
mailbox_unit: str,
ca_domain: str = "",
) -> None:
"""Create compliance calendar entries in ERPNext for recurring obligations.
Creates entries for:
- Anytime Mailbox renewal (annual)
- Provincial Annual Report/Return filing (annual)
- CRTC annual compliance check
- CCTS membership renewal (annual)
- Domain renewal (.ca, annual)
- Annual maintenance fee reminder ($349 USD)
"""
now = datetime.utcnow()
one_year = (now + timedelta(days=365)).strftime("%Y-%m-%d")
reminder_11mo = (now + timedelta(days=335)).strftime("%Y-%m-%d")
# Helper to build a compliant entry dict
def _entry(compliance_type, category, title, description,
due_date, reminder_date, amount_usd=0, amount_cad=0):
return {
"doctype": "Compliance Calendar",
"entity_name": entity_name,
"order_reference": order_number,
"title": title,
"compliance_type": compliance_type,
"category": category,
"description": description,
"due_date": due_date,
"reminder_date": reminder_date,
"amount_usd": amount_usd,
"amount_cad": amount_cad,
"state_code": province,
"recurring": 1,
"recurrence_period": "Yearly",
"status": "Upcoming",
}
entries = [
# 1. PW Annual Maintenance Fee — this is the primary billable renewal
_entry(
compliance_type="Annual Maintenance Fee",
category="Maintenance",
title=f"Annual Maintenance — {entity_name}",
description=(
f"Performance West annual maintenance fee for {entity_name}. "
f"Covers: registered agent service, CRTC compliance monitoring, "
f"{province} Annual Report/Return filing, REP-T/T1 survey preparation, "
f"mailbox monitoring, and general compliance support.\n"
f"Amount: $349.00 USD/yr. Invoice will be generated 30 days before due date."
),
due_date=one_year,
reminder_date=reminder_11mo,
amount_usd=349.00,
),
# 2. Mailbox renewal (vendor pass-through)
_entry(
compliance_type="Mailbox Renewal",
category="Mailbox",
title=f"Mailbox Renewal — {entity_name}",
description=(
f"Renew Anytime Mailbox plan at {self.prov_config['registered_office']['street']}, "
f"Unit #{mailbox_unit}, {self.prov_config['registered_office']['city']}, "
f"{province} {self.prov_config['registered_office']['postal_code']}.\n"
f"Vendor cost: C${self.prov_config['fees']['mailbox_yearly']:.2f}/yr (pass-through)."
),
due_date=one_year,
reminder_date=reminder_11mo,
amount_cad=self.prov_config["fees"]["mailbox_yearly"],
),
# 3. Provincial Annual Report/Return (government fee pass-through)
_entry(
compliance_type=f"{province} Annual Report",
category="Annual Report",
title=f"{province} Annual Report — {entity_name}",
description=(
f"File {province} Annual Report/Return for {entity_name} via "
f"{self.prov_config['filing_portal']['name']}. "
f"Due within 2 months of incorporation anniversary.\n"
f"Government fee: C${self.prov_config['fees']['annual_report']:.2f} (pass-through). "
f"Filing included in annual maintenance."
),
due_date=one_year,
reminder_date=reminder_11mo,
amount_cad=self.prov_config["fees"]["annual_report"],
),
# 4. CRTC compliance check (covered by maintenance fee)
_entry(
compliance_type="CRTC Compliance Check",
category="CRTC Filing",
title=f"CRTC Compliance Review — {entity_name}",
description=(
f"Annual CRTC compliance review for {entity_name}. "
f"Verify registration status, check for regulatory changes, "
f"confirm all filings are current. Included in annual maintenance."
),
due_date=one_year,
reminder_date=reminder_11mo,
),
# 5. CCTS membership (no fee)
_entry(
compliance_type="CCTS Membership",
category="CCTS",
title=f"CCTS Membership Review — {entity_name}",
description=(
f"Review CCTS membership status for {entity_name}. "
f"Ensure membership is active and contact info is current. "
f"No fee — included in annual maintenance.\n"
f"Website: https://www.ccts-cprst.ca"
),
due_date=one_year,
reminder_date=reminder_11mo,
),
]
# 6. Domain renewal (if applicable)
if ca_domain:
entries.append(_entry(
compliance_type="Domain Renewal",
category="Domain",
title=f".ca Domain Renewal — {ca_domain}",
description=(
f"Renew .ca domain: {ca_domain}. "
f"Registered via Porkbun. Auto-renewal should be enabled.\n"
f"Approximate cost: ~C$15/yr (pass-through)."
),
due_date=one_year,
reminder_date=reminder_11mo,
amount_cad=15.00,
))
# ── CRTC Annual Telecommunications Survey entries ──────────────
# These have FIXED March deadlines, not relative to incorporation.
# Calculate the next upcoming March deadline.
ats_config = self.prov_config.get("ats", {})
next_year = now.year + 1 if now.month >= 3 else now.year
# 7. REP-T/T1 — required for ALL carriers (no fee, covered by maintenance)
rep_t1 = ats_config.get("forms", {}).get("rep_t1", {})
rep_t1_due = f"{next_year}-03-01"
rep_t1_remind = f"{next_year}-02-01"
entries.append(_entry(
compliance_type="CRTC REP-T/T1 Survey",
category="CRTC Filing",
title=f"REP-T/T1 Annual Survey — {entity_name}",
description=(
f"File {rep_t1.get('name', 'REP-T/T1')} for {entity_name}.\n"
f"REQUIRED: {rep_t1.get('threshold', 'All registered carriers must file')}.\n"
f"Filed via My CRTC Account (GCKey). For Year 1 carriers with $0 revenue, "
f"most fields will be zero — Performance West will pre-fill the data sheet.\n"
f"Filing included in annual maintenance. Deadline: March 1."
),
due_date=rep_t1_due,
reminder_date=rep_t1_remind,
))
# 8-12. Threshold-based surveys (not billed separately — informational)
threshold_surveys = [
("rep_u", ats_config.get("forms", {}).get("rep_u", {})),
("form_802a", ats_config.get("forms", {}).get("form_802a", {})),
("form_802j", ats_config.get("forms", {}).get("form_802j", {})),
("facilities", ats_config.get("related_surveys", {}).get("facilities", {})),
("pricing", ats_config.get("related_surveys", {}).get("pricing", {})),
]
for survey_key, survey in threshold_surveys:
if not survey:
continue
deadline_month = survey.get("deadline_month", 3)
deadline_day = survey.get("deadline_day", 31)
survey_due = f"{next_year}-{deadline_month:02d}-{deadline_day:02d}"
survey_remind = f"{next_year}-{deadline_month:02d}-01"
entries.append(_entry(
compliance_type=f"CRTC {survey.get('name', survey_key)}",
category="CRTC Filing",
title=f"{survey.get('name', survey_key)}{entity_name}",
description=(
f"{survey.get('name', survey_key)} for {entity_name}.\n"
f"THRESHOLD: {survey.get('threshold', 'Check with CRTC')}.\n"
f"New carriers in Year 1 with <$10M CAD revenue are typically NOT required "
f"to file this survey. Contact Performance West to confirm.\n"
f"Deadline: {deadline_month}/{deadline_day}."
),
due_date=survey_due,
reminder_date=survey_remind,
))
# ── Provincial Corporate Tax & Filing Obligations ─────────────────
# Fixed calendar deadlines assuming Dec 31 fiscal year-end.
corp_oblig = self.prov_config.get("corporate_obligations", {})
# T2 Corporate Income Tax Return — June 30
t2_due = f"{next_year}-06-30"
t2_remind = f"{next_year}-05-01"
t2_cfg = corp_oblig.get("t2_return", {})
entries.append(_entry(
compliance_type="T2 Corporate Income Tax Return",
category="Annual Report",
title=f"T2 Income Tax Return — {entity_name}",
description=(
f"File federal T2 Corporate Income Tax Return for {entity_name} with the CRA.\n"
f"Required for ALL Canadian corporations, even with $0 income.\n"
f"Deadline: June 30 (6 months after Dec 31 fiscal year-end).\n"
f"Penalty for late filing: {t2_cfg.get('penalty', '5% of unpaid tax + 1%/month')}.\n"
f"Filed via CRA My Business Account or certified tax software.\n"
f"Performance West recommends engaging an accountant for T2 preparation."
),
due_date=t2_due,
reminder_date=t2_remind,
))
# Corporate Tax Payment — March 31 (CCPC under $500K)
tax_pay_due = f"{next_year}-03-31"
tax_pay_remind = f"{next_year}-02-15"
entries.append(_entry(
compliance_type="Corporate Tax Payment",
category="Franchise Tax",
title=f"Corporate Tax Payment — {entity_name}",
description=(
f"Pay any corporate income tax owing for {entity_name}.\n"
f"Due 3 months after fiscal year-end for CCPCs with taxable income under $500K.\n"
f"Deadline: March 31 (for Dec 31 fiscal year-end).\n"
f"Interest accrues on late payments from the due date.\n"
f"For Year 1 with $0 income, $0 will be owing — but the deadline still applies."
),
due_date=tax_pay_due,
reminder_date=tax_pay_remind,
))
# GST/HST Return — March 31 (annual filers)
gst_cfg = corp_oblig.get("gst_hst_return", {})
gst_due = f"{next_year}-03-31"
gst_remind = f"{next_year}-02-15"
entries.append(_entry(
compliance_type="GST/HST Return",
category="Annual Report",
title=f"GST/HST Return — {entity_name}",
description=(
f"File GST/HST return for {entity_name} with the CRA.\n"
f"Telecom services are generally GST/HST taxable (5% federal).\n"
f"Registration is mandatory if annual revenue exceeds $30K; "
f"voluntary registration is recommended for input tax credit recovery.\n"
f"Annual filers: due March 31 (3 months after Dec 31 fiscal year-end).\n"
f"For Year 1 with minimal activity, the return may show a refund "
f"(ITCs on incorporation and setup expenses)."
),
due_date=gst_due,
reminder_date=gst_remind,
))
# T4/T4A Information Slips — Feb 28 (only if employees/contractors)
entries.append(_entry(
compliance_type="T4/T4A Information Slips",
category="Annual Report",
title=f"T4/T4A Slips (if applicable) — {entity_name}",
description=(
f"File T4 (employment income) and/or T4A (contractor) slips for {entity_name}.\n"
f"THRESHOLD: Only required if the corporation paid employees or contractors >$500/yr.\n"
f"Most new telecom shell corporations have no employees — verify with your accountant.\n"
f"Deadline: Last day of February."
),
due_date=f"{next_year}-02-28",
reminder_date=f"{next_year}-01-15",
))
# CRTC Annual Registration Update — Q1 (CRTC-initiated)
entries.append(_entry(
compliance_type="CRTC Registration Update",
category="CRTC Filing",
title=f"CRTC Registration Update — {entity_name}",
description=(
f"The CRTC contacts registered carriers annually to verify registration info.\n"
f"You must respond within 30 days to maintain active registration.\n"
f"The CRTC initiates this — monitor regulatory@{ca_domain} for their letter.\n"
f"Included in annual maintenance — Performance West will handle the response."
if ca_domain else
f"The CRTC contacts registered carriers annually to verify registration info.\n"
f"You must respond within 30 days to maintain active registration.\n"
f"Included in annual maintenance — Performance West will handle the response."
),
due_date=f"{next_year}-03-31",
reminder_date=f"{next_year}-01-15",
))
for entry in entries:
try:
erp.create_resource("Compliance Calendar", entry)
LOG.info(
"Created compliance entry: %s for %s (due %s)",
entry["compliance_type"],
entity_name,
entry["due_date"],
)
except Exception as exc:
LOG.error(
"Failed to create compliance entry '%s' for %s: %s",
entry["compliance_type"],
entity_name,
exc,
)
# ------------------------------------------------------------------ #
# Email helpers
# ------------------------------------------------------------------ #
def _send_client_email(
self,
to_email: str,
client_name: str,
entity_name: str,
order_number: str,
binder_path: str,
mailbox_unit: str = "",
ca_did: str = "",
ca_domain: str = "",
has_own_ca: bool = False,
own_registered_office: str = "",
from_addr: str | None = None,
from_password: str | None = None,
) -> None:
"""Email the corporate binder PDF to the client.
has_own_ca=True → client supplied their own Canadian address; skip AMB activation
instructions; use own_registered_office as the address.
has_own_ca=False → AMB order; show mailbox activation instructions.
from_addr / from_password: if provided, send FROM the client's own
regulatory@domain.ca account so the email arrives from their Canadian
identity (not from PW). Requires HestiaCP SMTP credentials.
"""
subject = f"Your {prov_name} Corporation & CRTC Registration — {entity_name}"
_ro = self.prov_config["registered_office"]
_default_addr = f"{_ro['street']}, {_ro['city']}, {province} {_ro['postal_code']}"
if has_own_ca and own_registered_office:
registered_office_text = own_registered_office.strip()
else:
registered_office_text = (
f"{_ro['street']}, Suite {mailbox_unit}\n{_ro['city']}, {province} {_ro['postal_code']}\nCanada"
if mailbox_unit
else f"{_default_addr}, Canada (unit assigned — check portal)"
)
did_line = f"\n Canadian phone (DID): {ca_did}" if ca_did else ""
domain_line = f"\n Canadian domain: {ca_domain}" if ca_domain else ""
# AMB activation section — only shown when we set up the mailbox for them
if has_own_ca:
mailbox_section = (
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"YOUR REGISTERED OFFICE\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"Your corporation is registered at the {prov_name} address you provided:\n\n"
f" {registered_office_text}\n\n"
f"Your corporate binder must be kept at this address and be available "
f"for inspection during business hours under provincial corporations legislation.\n\n"
)
else:
mailbox_section = (
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"ACTION REQUIRED — COMPLETE YOUR VIRTUAL MAILBOX\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"We have set up your Anytime Mailbox account at the address above. To activate "
f"it and start receiving mail, you must complete identity verification inside the "
f"Anytime Mailbox app:\n\n"
f" 1. Download the Anytime Mailbox app (iOS or Android) or log in at\n"
f" https://app.anytimemailbox.com\n"
f" 2. Sign in with the email address you provided on your order.\n"
f" 3. Go to Account > Verification and upload a photo of your\n"
f" government-issued photo ID (passport or driver's licence).\n"
f" 4. Wait for the mailbox operator to approve (usually same business day).\n\n"
f"Your registered office address will not accept mail until this step is complete.\n\n"
)
body = (
f"Dear {client_name},\n\n"
f"Your {prov_name} corporation has been incorporated and your CRTC carrier registration is "
f"underway. Your corporate binder is attached.\n\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"YOUR CARRIER DETAILS\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f" Corporation: {entity_name}\n"
f" Order: {order_number}\n"
f" Registered office:\n {registered_office_text}"
f"{did_line}"
f"{domain_line}\n\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"YOUR BINDER INCLUDES\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f" - Certificate of Incorporation\n"
f" - Articles of Incorporation\n"
f" - CRTC Notification Letter\n"
f" - Registered Office details\n\n"
f"A physical copy will also be shipped to your registered office address.\n\n"
f"{mailbox_section}"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"UPCOMING DATES\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f" - CRTC registration: confirmation expected within 24 weeks\n"
f" - {province} Annual Report/Return: due within 2 months of your incorporation anniversary each year\n"
f" - Mailbox renewal: annual — we will send reminders\n\n"
f"If you have any questions, reply to this email or open a ticket at\n"
f"https://performancewest.net/contact\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
f"https://performancewest.net\n"
)
self._send_email(
to_email=to_email,
subject=subject,
body=body,
attachment_path=binder_path,
from_addr=from_addr,
from_password=from_password,
)
def _send_admin_print_email(
self,
entity_name: str,
order_number: str,
mailbox_unit: str,
binder_path: str,
has_own_ca: bool = False,
binder_company: str = "",
binder_attn: str = "",
mailbox_address: str = "",
) -> None:
"""Email print/ship instructions to ops@performancewest.net.
Shipping label format:
AMB order:
Attn: <entity_name>
c/o <operator_name> ← binder_company holds the AMB operator
<street>, Unit #<unit>
<city>, <province> <postal>, Canada
Own-address order:
Attn: <binder_attn or entity_name>
<binder_company>
<mailbox_address>
Canada
"""
subject = (
f"[ACTION] Print & Ship Corporate Binder — "
f"{entity_name}{order_number}"
)
if has_own_ca and mailbox_address:
# Client's own Canadian address — use their company/attn
attn_line = binder_attn or entity_name
company_line = binder_company or entity_name
addr_line = mailbox_address.strip().rstrip(",")
ship_to = (
f" Attn: {attn_line}\n"
f" {company_line}\n"
f" {addr_line}\n"
f" Canada"
)
note = (
f"This client has their own Canadian address. No mailbox unit to add.\n"
f"Make sure the company name on the parcel matches exactly.\n"
)
else:
# AMB order — binder_company is the mailbox operator name
unit_str = f"Unit #{mailbox_unit}" if mailbox_unit else "(unit TBD)"
# Parse mailbox_address to get street/postal if not default
if mailbox_address:
import re as _re
_parts = [p.strip() for p in mailbox_address.split(",")]
_ro = self.prov_config["registered_office"]
_street = _parts[0] if _parts else _ro["street"]
_city_prov = ", ".join(_parts[1:]) if len(_parts) > 1 else f"{_ro['city']}, {_ro['province']} {_ro['postal_code']}"
full_addr = f"{_street}, {unit_str}\n {_city_prov}"
else:
_ro = self.prov_config["registered_office"]
full_addr = f"{_ro['street']}, {unit_str}\n {_ro['city']}, {_ro['province']} {_ro['postal_code']}"
co_line = f" c/o {binder_company}\n" if binder_company else ""
ship_to = (
f" {entity_name}\n"
f"{co_line}"
f" {full_addr}\n"
f" Canada"
)
note = "Use PirateShip for cheapest USPS/UPS rate.\n"
body = (
f"Ship FROM:\n"
f" Performance West Inc.\n"
f" Dallas, TX 75218\n"
f"\n"
f"Ship TO:\n"
f"{ship_to}\n"
f"\n"
f"{note}"
f"Enter tracking number in ERPNext order #{order_number} when shipped.\n"
f"\n"
f"Attached: corporate-binder-{order_number}.pdf (print double-sided)\n"
)
self._send_email(
to_email=ADMIN_EMAIL,
subject=subject,
body=body,
attachment_path=binder_path,
)
def _send_banking_referral_email(
self,
to_email: str,
client_name: str,
entity_name: str,
referral_url: str,
) -> None:
"""Send the Canadian banking referral email to the client."""
subject = f"Set Up Your Canadian Business Bank Account — {entity_name}"
body = (
f"Dear {client_name},\n\n"
f"Your corporation ({entity_name}) is set up and your CRTC registration is underway.\n\n"
f"The next step is to open a Canadian business bank account. You can open one "
f"entirely online with no in-person visit required.\n\n"
f"This is important for receiving Canadian payments, paying Canadian vendors, "
f"and managing your corporation's finances.\n\n"
f"If you have questions about Canadian banking, GST/HST, or corporate tax setup, "
f"your package includes 3 hours of Canadian accounting consultation — "
f"reply to this email to schedule.\n\n"
f"Best regards,\n"
f"Performance West Inc.\n"
)
self._send_email(to_email=to_email, subject=subject, body=body)
@staticmethod
def _send_email(
self,
to_email: str,
subject: str,
body: str,
attachment_path: str | None = None,
from_addr: str | None = None,
from_password: str | None = None,
from_smtp_host: str | None = None,
from_smtp_port: int | None = None,
) -> None:
"""Send an email via SMTP with optional PDF attachment.
By default sends FROM the Performance West Carbonio account
(noreply@performancewest.net via co.carrierone.com).
When sending the CRTC notification letter, pass from_addr and
from_password to send FROM regulatory@domain.ca using the
HestiaCP mail server (also co.carrierone.com), so the letter
arrives with the client's own Canadian domain in the From header.
Args:
to_email: Recipient address
subject: Email subject line
body: Plain-text body
attachment_path: Optional PDF to attach
from_addr: Override FROM address (e.g. "regulatory@valleyinternet.ca")
from_password: SMTP password for from_addr
from_smtp_host: Override SMTP host (defaults to SMTP_HOST / co.carrierone.com)
from_smtp_port: Override SMTP port (defaults to SMTP_PORT / 587)
"""
# Resolve sender credentials
effective_from = from_addr or SMTP_FROM
effective_user = from_addr or SMTP_USER
effective_password = from_password or SMTP_PASSWORD
effective_host = from_smtp_host or SMTP_HOST
effective_port = from_smtp_port or SMTP_PORT
if not effective_user or not effective_password:
LOG.warning(
"SMTP credentials not configured — skipping email to %s (from: %s)",
to_email, effective_from,
)
return
msg = MIMEMultipart()
msg["From"] = effective_from
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
# Attach PDF or other file
if attachment_path and Path(attachment_path).exists():
with open(attachment_path, "rb") as f:
part = MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f"attachment; filename={Path(attachment_path).name}",
)
msg.attach(part)
try:
with smtplib.SMTP(effective_host, effective_port) as server:
server.starttls()
server.login(effective_user, effective_password)
server.send_message(msg)
LOG.info(
"Email sent from %s to %s: %s",
effective_from, to_email, subject,
)
except Exception as exc:
LOG.error(
"Failed to send email from %s to %s: %s",
effective_from, to_email, exc,
)