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>
2372 lines
114 KiB
Python
2372 lines
114 KiB
Python
"""
|
||
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 2–4 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,
|
||
)
|