Initial commit — Performance West telecom compliance platform

Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -0,0 +1,230 @@
"""
Whitelisted API for Canadian Registry Services.
Endpoints:
file_incorporation(filing_name) execute a CA Filing Request
process_pending_filings() scheduler: process all pending filings
"""
import frappe
import json
import logging
LOG = logging.getLogger("frappe_ca_registry.api")
@frappe.whitelist()
def file_incorporation(filing_name: str) -> dict:
"""
Execute a BC incorporation filing from a CA Filing Request record.
Loads the filing request, decrypts the payment card from Sensitive ID,
dispatches to the correct province adapter, and writes results back.
Args:
filing_name: Name of the CA Filing Request DocType record
Returns:
{"success": bool, "incorporation_number": str, "error": str}
"""
filing = frappe.get_doc("CA Filing Request", filing_name)
if filing.status not in ("Pending", "Failed"):
return {"success": False, "error": f"Filing is {filing.status}, not Pending/Failed"}
# Load the province adapter
province = filing.province or "BC"
from frappe_ca_registry.provinces import get_adapter
adapter_cls = get_adapter(province)
if not adapter_cls:
filing.db_set("status", "Failed")
filing.db_set("error_message", f"No adapter for province: {province}")
return {"success": False, "error": f"No adapter for province: {province}"}
# Decrypt the filing card from ERPNext Sensitive ID
card_name = filing.payment_card or "relay-filing-card"
try:
card_data = frappe.call(
"performancewest_erpnext.api.get_filing_card",
card_name=card_name,
)
except Exception as e:
filing.db_set("status", "Failed")
filing.db_set("error_message", f"Could not load payment card: {e}")
return {"success": False, "error": f"Card load failed: {e}"}
# Build filing data dict from the DocType fields
filing_data = {
"company_type": filing.company_type,
"name_reservation_number": filing.name_reservation_number or "",
"trade_name": filing.trade_name or "",
"effective_date_type": filing.effective_date_type or "Immediately",
# Director (customer) — used for COLIN Step 6
"director_first_name": filing.director_first_name,
"director_middle_name": filing.director_middle_name or "",
"director_last_name": filing.director_last_name,
"director_address": filing.director_address or "",
"director_address2": filing.director_address2 or "",
"director_city": filing.director_city or "",
"director_province": filing.director_province or "",
"director_postal": filing.director_postal or "",
"director_country": filing.director_country or "US",
# Director mailing address (if different)
"director_mailing_different": filing.director_mailing_different or False,
"director_mailing_street": filing.director_mailing_street or "",
"director_mailing_city": filing.director_mailing_city or "",
"director_mailing_province": filing.director_mailing_province or "",
"director_mailing_postal": filing.director_mailing_postal or "",
"director_mailing_country": filing.director_mailing_country or "",
# Additional directors (JSON)
"additional_directors": json.loads(filing.additional_directors_json or "[]"),
# Registered office
"office_address": filing.office_address or "329 Howe St",
"office_city": filing.office_city or "Vancouver",
"office_postal": filing.office_postal or "V6C 3N2",
# Shares
"share_class": filing.share_class or "Common",
"shares_authorized": filing.shares_authorized or 100,
}
# Execute the filing
filing.db_set("status", "In Progress")
frappe.db.commit()
adapter = adapter_cls()
try:
result = adapter.file_incorporation(filing_data, card_data)
except Exception as exc:
# Catch-all: if the adapter throws an unhandled exception,
# still set status to Failed so on_update triggers the alert chain.
from frappe_ca_registry.provinces.bc.adapter import FilingResult
result = FilingResult(success=False, error=f"Unhandled exception: {exc}")
frappe.log_error(
title=f"CA Filing {filing.name} crashed",
message=frappe.get_traceback(),
)
# Upload screenshots to MinIO and attach privately to the filing record
order_num = filing.external_order_id or filing.name
if result.screenshots:
try:
minio_paths = adapter.upload_screenshots_to_minio(
result.screenshots, order_num, filing.name
)
result.screenshots = minio_paths # Replace local paths with MinIO paths
except Exception as e:
LOG.warning("Screenshot upload failed: %s", e)
# Write results back to the DocType
if result.success:
filing.db_set("status", "Completed")
filing.db_set("incorporation_number", result.incorporation_number)
filing.db_set("company_name_final", result.company_name)
filing.db_set("filing_confirmation_number", result.confirmation_number)
filing.db_set("government_fee_paid", result.government_fee)
filing.db_set("filed_at", result.filed_at)
filing.db_set("screenshots", json.dumps(result.screenshots))
# Update the linked Sales Order if present
if filing.sales_order:
try:
so = frappe.get_doc("Sales Order", filing.sales_order)
so.db_set("custom_incorporation_number", result.incorporation_number)
so.db_set("custom_company_name_final", result.company_name)
frappe.db.commit()
except Exception as e:
LOG.warning("Could not update Sales Order %s: %s", filing.sales_order, e)
# Update the PG orders table
if filing.external_order_id:
try:
import psycopg2
import os
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
cur.execute(
"UPDATE canada_crtc_orders SET incorporation_number=%s, company_name_final=%s WHERE order_number=%s",
(result.incorporation_number, result.company_name, filing.external_order_id),
)
conn.commit()
conn.close()
except Exception as e:
LOG.warning("Could not update PG order %s: %s", filing.external_order_id, e)
else:
filing.db_set("status", "Failed")
filing.db_set("error_message", result.error)
filing.db_set("screenshots", json.dumps(result.screenshots))
frappe.db.commit()
return {
"success": result.success,
"incorporation_number": result.incorporation_number,
"company_name": result.company_name,
"error": result.error,
}
@frappe.whitelist()
def execute_filing(filing_name: str):
"""Background job wrapper for file_incorporation."""
return file_incorporation(filing_name)
@frappe.whitelist()
def get_screenshot(path: str):
"""
Serve a filing screenshot from MinIO. Restricted to System Manager.
The screenshots are stored as private ERPNext File attachments on the
CA Filing Request DocType. This endpoint streams the actual PNG from
MinIO when a System Manager clicks the attachment.
"""
if "System Manager" not in frappe.get_roles():
frappe.throw("Only System Manager can view filing screenshots", frappe.PermissionError)
import os
from minio import Minio
minio = Minio(
f"{os.environ.get('MINIO_ENDPOINT', 'minio')}:{os.environ.get('MINIO_PORT', '9000')}",
access_key=os.environ.get("MINIO_ACCESS_KEY", ""),
secret_key=os.environ.get("MINIO_SECRET_KEY", ""),
secure=os.environ.get("MINIO_SECURE", "false").lower() == "true",
)
try:
response = minio.get_object("performancewest", path)
frappe.local.response.filename = path.split("/")[-1]
frappe.local.response.filecontent = response.read()
frappe.local.response.type = "download"
frappe.local.response.display_content_as = "inline"
except Exception as e:
frappe.throw(f"Screenshot not found: {e}")
def process_pending_filings():
"""
Scheduler: process all pending CA Filing Requests.
Called every 5 minutes by the scheduler hook.
"""
pending = frappe.get_all(
"CA Filing Request",
filters={"status": "Pending", "docstatus": 1},
fields=["name"],
order_by="creation ASC",
limit=5,
)
for filing in pending:
try:
file_incorporation(filing.name)
except Exception as e:
LOG.error("Failed to process filing %s: %s", filing.name, e)
frappe.log_error(
title=f"CA Filing scheduler failed: {filing.name}",
message=frappe.get_traceback(),
)
doc = frappe.get_doc("CA Filing Request", filing.name)
doc.db_set("error_message", str(e)[:500])
doc.db_set("status", "Failed") # Triggers on_update → _alert_failure
frappe.db.commit()

View file

@ -0,0 +1,26 @@
from . import __version__ as app_version
app_name = "frappe_ca_registry"
app_title = "Canadian Registry Services"
app_publisher = "Performance West Inc."
app_description = (
"Canadian corporate registry automation for Frappe/ERPNext. "
"Automates incorporation, name reservation, trade name registration, "
"and annual reports via Playwright browser automation. "
"BC (Corporate Online / COLIN) is the first province adapter. "
"Reads filing payment card from ERPNext Sensitive ID vault."
)
app_email = "support@performancewest.net"
app_license = "MIT"
# Install hooks
after_install = "frappe_ca_registry.install.after_install"
# Scheduler hooks — check for pending filings every 5 minutes
scheduler_events = {
"cron": {
"*/5 * * * *": [
"frappe_ca_registry.api.process_pending_filings",
],
},
}

View file

@ -0,0 +1,101 @@
frappe.ui.form.on("CA Filing Request", {
refresh(frm) {
// ── Retry button (only on Failed filings) ────────────────
if (frm.doc.status === "Failed" && frm.doc.docstatus === 1) {
frm.add_custom_button(
__("Retry Filing"),
function () {
frappe.confirm(
`Retry filing <b>${frm.doc.name}</b>?<br><br>` +
`This will re-run the automation with the same data. ` +
`Attempt #${(frm.doc.retry_count || 0) + 2}.`,
function () {
frm.call("retry_filing").then(() => frm.reload_doc());
}
);
},
__("Actions")
);
}
// ── Mark as Manual Complete (on Failed filings) ──────────
if (frm.doc.status === "Failed" && frm.doc.docstatus === 1) {
frm.add_custom_button(
__("Mark as Manual Complete"),
function () {
let d = new frappe.ui.Dialog({
title: "Mark Filing as Manually Completed",
fields: [
{
label: "BC Incorporation Number",
fieldname: "incorporation_number",
fieldtype: "Data",
reqd: 1,
description: "Enter the incorporation number from the BC Registry certificate",
},
{
label: "Company Name",
fieldname: "company_name_final",
fieldtype: "Data",
description: "e.g. '1234567 B.C. Ltd.'",
},
{
label: "Government Fee Paid (CAD)",
fieldname: "government_fee_paid",
fieldtype: "Currency",
default: 350.0,
},
],
primary_action_label: "Mark Complete",
primary_action(values) {
frappe.call({
method: "frappe.client.set_value",
args: {
doctype: "CA Filing Request",
name: frm.doc.name,
fieldname: {
incorporation_number: values.incorporation_number,
company_name_final: values.company_name_final || "",
government_fee_paid: values.government_fee_paid || 350,
},
},
callback: function () {
frm.call("mark_manual_complete").then(() => {
d.hide();
frm.reload_doc();
});
},
});
},
});
d.show();
},
__("Actions")
);
}
// ── Status indicator colors ──────────────────────────────
if (frm.doc.status === "Completed") {
frm.dashboard.set_headline(
`<span class="indicator-pill green">
<span class="indicator green"></span>
Filed BC# ${frm.doc.incorporation_number || ""}
</span>`
);
} else if (frm.doc.status === "Failed") {
frm.dashboard.set_headline(
`<span class="indicator-pill red">
<span class="indicator red"></span>
Failed ${(frm.doc.error_message || "").substring(0, 80)}
</span>`
);
} else if (frm.doc.status === "In Progress") {
frm.dashboard.set_headline(
`<span class="indicator-pill orange">
<span class="indicator orange"></span>
Filing in progress...
</span>`
);
}
},
});

View file

@ -0,0 +1,353 @@
{
"doctype": "DocType",
"name": "CA Filing Request",
"module": "Incorporation",
"custom": 0,
"autoname": "format:CAF-{####}",
"is_submittable": 1,
"allow_amend": 1,
"track_changes": 1,
"sort_field": "creation",
"sort_order": "DESC",
"fields": [
{
"fieldname": "filing_type",
"fieldtype": "Select",
"label": "Filing Type",
"options": "Numbered Incorporation\nNamed Incorporation\nName Reservation\nTrade Name Registration\nAnnual Report",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "province",
"fieldtype": "Select",
"label": "Province",
"options": "BC\nAB\nON\nFederal",
"default": "BC",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Pending\nIn Progress\nCompleted\nFailed\nCancelled",
"default": "Pending",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "sales_order",
"fieldtype": "Link",
"label": "Sales Order",
"options": "Sales Order",
"description": "The ERPNext Sales Order this filing is for"
},
{
"fieldname": "cb_filing_1",
"fieldtype": "Column Break"
},
{
"fieldname": "external_order_id",
"fieldtype": "Data",
"label": "External Order ID",
"description": "Order number from the PG orders table (e.g. CA-2026-XXXXX)"
},
{
"fieldname": "payment_card",
"fieldtype": "Link",
"label": "Payment Card",
"options": "Sensitive ID",
"default": "relay-filing-card",
"description": "ERPNext Sensitive ID record containing the Relay virtual debit card for filing fees"
},
{
"fieldname": "sb_company",
"fieldtype": "Section Break",
"label": "Company Details"
},
{
"fieldname": "company_type",
"fieldtype": "Select",
"label": "Company Type",
"options": "Numbered\nNamed\nNumbered + Trade Name",
"default": "Numbered"
},
{
"fieldname": "name_reservation_number",
"fieldtype": "Data",
"label": "Name Reservation Number",
"depends_on": "eval:doc.company_type=='Named'",
"description": "BC Name Reservation number (if named company)"
},
{
"fieldname": "trade_name",
"fieldtype": "Data",
"label": "Trade Name",
"depends_on": "eval:doc.company_type=='Numbered + Trade Name'",
"description": "DBA / trade name for numbered + trade name companies"
},
{
"fieldname": "cb_company_1",
"fieldtype": "Column Break"
},
{
"fieldname": "effective_date_type",
"fieldtype": "Select",
"label": "Effective Date",
"options": "Immediately\nFuture Date",
"default": "Immediately"
},
{
"fieldname": "future_effective_date",
"fieldtype": "Datetime",
"label": "Future Effective Date",
"depends_on": "eval:doc.effective_date_type=='Future Date'"
},
{
"fieldname": "sb_director",
"fieldtype": "Section Break",
"label": "Director (Customer)"
},
{
"fieldname": "director_first_name",
"fieldtype": "Data",
"label": "First Name",
"reqd": 1
},
{
"fieldname": "director_middle_name",
"fieldtype": "Data",
"label": "Middle Name"
},
{
"fieldname": "director_last_name",
"fieldtype": "Data",
"label": "Last Name",
"reqd": 1
},
{
"fieldname": "director_address",
"fieldtype": "Data",
"label": "Street Address"
},
{
"fieldname": "director_address2",
"fieldtype": "Data",
"label": "Address Line 2"
},
{
"fieldname": "director_city",
"fieldtype": "Data",
"label": "City"
},
{
"fieldname": "cb_director_1",
"fieldtype": "Column Break"
},
{
"fieldname": "director_province",
"fieldtype": "Data",
"label": "Province / State"
},
{
"fieldname": "director_postal",
"fieldtype": "Data",
"label": "Postal / ZIP Code"
},
{
"fieldname": "director_country",
"fieldtype": "Data",
"label": "Country",
"default": "US"
},
{
"fieldname": "sb_director_mailing",
"fieldtype": "Section Break",
"label": "Director Mailing Address",
"collapsible": 1,
"description": "Only fill if different from delivery address above"
},
{
"fieldname": "director_mailing_different",
"fieldtype": "Check",
"label": "Mailing address is different"
},
{
"fieldname": "director_mailing_street",
"fieldtype": "Data",
"label": "Mailing Street",
"depends_on": "eval:doc.director_mailing_different"
},
{
"fieldname": "director_mailing_city",
"fieldtype": "Data",
"label": "Mailing City",
"depends_on": "eval:doc.director_mailing_different"
},
{
"fieldname": "director_mailing_province",
"fieldtype": "Data",
"label": "Mailing Province / State",
"depends_on": "eval:doc.director_mailing_different"
},
{
"fieldname": "director_mailing_postal",
"fieldtype": "Data",
"label": "Mailing Postal / ZIP",
"depends_on": "eval:doc.director_mailing_different"
},
{
"fieldname": "director_mailing_country",
"fieldtype": "Data",
"label": "Mailing Country",
"depends_on": "eval:doc.director_mailing_different"
},
{
"fieldname": "sb_additional_directors",
"fieldtype": "Section Break",
"label": "Additional Directors",
"collapsible": 1,
"description": "JSON array of additional directors from the order form"
},
{
"fieldname": "additional_directors_json",
"fieldtype": "Code",
"label": "Additional Directors (JSON)",
"options": "JSON",
"description": "Array of {first_name, middle_name, last_name, street, city, province, postal, country}"
},
{
"fieldname": "sb_registered_office",
"fieldtype": "Section Break",
"label": "Registered Office (must be in BC)"
},
{
"fieldname": "office_address",
"fieldtype": "Data",
"label": "Street Address",
"default": "329 Howe St"
},
{
"fieldname": "office_city",
"fieldtype": "Data",
"label": "City",
"default": "Vancouver"
},
{
"fieldname": "office_postal",
"fieldtype": "Data",
"label": "Postal Code",
"default": "V6C 3N2"
},
{
"fieldname": "sb_shares",
"fieldtype": "Section Break",
"label": "Share Structure"
},
{
"fieldname": "share_class",
"fieldtype": "Data",
"label": "Share Class",
"default": "Common"
},
{
"fieldname": "shares_authorized",
"fieldtype": "Int",
"label": "Shares Authorized",
"default": 10000
},
{
"fieldname": "par_value",
"fieldtype": "Check",
"label": "Has Par Value",
"default": 0
},
{
"fieldname": "sb_notification",
"fieldtype": "Section Break",
"label": "Notification Email"
},
{
"fieldname": "notification_email",
"fieldtype": "Data",
"label": "Email for BC Registry Notices",
"options": "Email",
"description": "BC Registry sends the Certificate of Incorporation to this email"
},
{
"fieldname": "sb_results",
"fieldtype": "Section Break",
"label": "Filing Results"
},
{
"fieldname": "incorporation_number",
"fieldtype": "Data",
"label": "BC Incorporation Number",
"in_list_view": 1,
"description": "Assigned by BC Registry on successful filing. Admin can enter manually if filed by hand."
},
{
"fieldname": "company_name_final",
"fieldtype": "Data",
"label": "Company Name (Final)",
"description": "e.g. '1234567 B.C. Ltd.' for numbered companies"
},
{
"fieldname": "filing_confirmation_number",
"fieldtype": "Data",
"label": "Filing Confirmation Number"
},
{
"fieldname": "cb_results_1",
"fieldtype": "Column Break"
},
{
"fieldname": "government_fee_paid",
"fieldtype": "Currency",
"label": "Government Fee Paid"
},
{
"fieldname": "filed_at",
"fieldtype": "Datetime",
"label": "Filed At"
},
{
"fieldname": "retry_count",
"fieldtype": "Int",
"label": "Retry Count",
"default": 0,
"read_only": 1,
"description": "Number of times this filing has been attempted"
},
{
"fieldname": "error_message",
"fieldtype": "Small Text",
"label": "Error Message",
"read_only": 1,
"depends_on": "eval:doc.status=='Failed'"
},
{
"fieldname": "sb_screenshots",
"fieldtype": "Section Break",
"label": "Automation Screenshots",
"collapsible": 1
},
{
"fieldname": "screenshots",
"fieldtype": "Text",
"label": "Screenshot Paths",
"read_only": 1,
"description": "JSON list of screenshot file paths captured during automation"
}
],
"permissions": [
{
"role": "System Manager",
"read": 1,
"write": 1,
"create": 1,
"delete": 1
}
]
}

View file

@ -0,0 +1,252 @@
import frappe
from frappe.model.document import Document
ADMIN_EMAIL = "ops@performancewest.net"
MAX_AUTO_RETRIES = 3
class CAFilingRequest(Document):
def validate(self):
if self.company_type == "Named" and not self.name_reservation_number:
frappe.throw("Name Reservation Number is required for named companies")
if self.company_type == "Numbered + Trade Name" and not self.trade_name:
frappe.throw("Trade Name is required for numbered + trade name companies")
if not self.director_first_name or not self.director_last_name:
frappe.throw("Director first and last name are required")
def on_submit(self):
"""Queue the filing for background processing."""
frappe.enqueue(
"frappe_ca_registry.api.execute_filing",
filing_name=self.name,
queue="long",
timeout=600,
)
self.db_set("status", "In Progress")
def on_cancel(self):
"""Mark as cancelled — stops any pending retries."""
self.db_set("status", "Cancelled")
def on_update(self):
"""Send alerts when filing status changes."""
if self.has_value_changed("status"):
if self.status == "Failed":
self._alert_failure()
elif self.status == "Completed":
self._alert_success()
# ── Admin actions (called from form buttons or API) ──────
@frappe.whitelist()
def retry_filing(self):
"""
Retry a failed filing. Resets status to Pending and re-queues.
Available as a button on the form when status is Failed.
Usage from ERPNext UI: Click "Retry" button on the filing form.
Usage from API: POST /api/method/frappe.client.run_doc_method
{"docs": {...}, "method": "retry_filing"}
"""
if self.status not in ("Failed",):
frappe.throw(f"Cannot retry a filing with status: {self.status}")
if self.retry_count >= MAX_AUTO_RETRIES:
frappe.throw(
f"Maximum retries ({MAX_AUTO_RETRIES}) reached. "
f"Use 'Amend' to create a new filing with corrected data, "
f"or 'Mark as Manual' to enter the incorporation number by hand."
)
self.db_set("retry_count", (self.retry_count or 0) + 1)
self.db_set("status", "Pending")
self.db_set("error_message", "")
frappe.db.commit()
frappe.enqueue(
"frappe_ca_registry.api.execute_filing",
filing_name=self.name,
queue="long",
timeout=600,
)
self.db_set("status", "In Progress")
frappe.msgprint(
f"Filing {self.name} re-queued for processing (attempt #{self.retry_count + 1}).",
title="Retry Queued",
indicator="blue",
)
@frappe.whitelist()
def mark_manual_complete(self):
"""
Mark a filing as completed manually. Admin must enter the
incorporation number and company name before calling this.
Used when the admin files manually through the COLIN portal
instead of using the automation.
"""
if not self.incorporation_number:
frappe.throw("Enter the BC Incorporation Number before marking as complete")
self.db_set("status", "Completed")
self.db_set("filed_at", frappe.utils.now())
if not self.company_name_final and self.company_type == "Numbered":
self.db_set("company_name_final", f"{self.incorporation_number} B.C. Ltd.")
# Update linked Sales Order
if self.sales_order:
try:
frappe.db.set_value("Sales Order", self.sales_order,
"custom_incorporation_number", self.incorporation_number)
frappe.db.set_value("Sales Order", self.sales_order,
"custom_company_name_final", self.company_name_final)
except Exception as e:
frappe.log_error(f"Could not update Sales Order: {e}")
# Update PG
if self.external_order_id:
try:
import psycopg2, os
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
cur = conn.cursor()
cur.execute(
"UPDATE canada_crtc_orders SET incorporation_number=%s, company_name_final=%s WHERE order_number=%s",
(self.incorporation_number, self.company_name_final, self.external_order_id),
)
conn.commit()
conn.close()
except Exception as e:
frappe.log_error(f"Could not update PG order: {e}")
frappe.db.commit()
frappe.msgprint(
f"Filing {self.name} marked as completed. BC# {self.incorporation_number}",
title="Manual Complete",
indicator="green",
)
# ── Alert helpers ────────────────────────────────────────
def _alert_failure(self):
"""Alert admin on filing failure via Error Log + Notification + Email + ToDo."""
title = f"BC Filing FAILED — {self.name}"
retry_note = ""
if self.retry_count and self.retry_count >= MAX_AUTO_RETRIES:
retry_note = (
f"<br><b>Max retries ({MAX_AUTO_RETRIES}) reached.</b> "
f"Use Amend to correct data, or Mark as Manual to enter results by hand."
)
message = (
f"<b>CA Filing Request:</b> {self.name}<br>"
f"<b>Province:</b> {self.province}<br>"
f"<b>Filing Type:</b> {self.filing_type}<br>"
f"<b>Company Type:</b> {self.company_type}<br>"
f"<b>Director:</b> {self.director_first_name} {self.director_last_name}<br>"
f"<b>Sales Order:</b> {self.sales_order or 'N/A'}<br>"
f"<b>Retry Count:</b> {self.retry_count or 0}<br><br>"
f"<b>Error:</b><br><pre>{self.error_message or 'Unknown error'}</pre>"
f"{retry_note}<br><br>"
f"<a href='/app/ca-filing-request/{self.name}'>View Filing Request →</a>"
)
# 1. Error Log
frappe.log_error(
title=title,
message=f"Filing: {self.name}\nRetry: {self.retry_count}\nError: {self.error_message}",
)
# 2. Bell notification
_create_notification(
for_user="Administrator",
from_user="Administrator",
doc=self,
subject=title,
message=message,
type="Alert",
)
# 3. Email
try:
frappe.sendmail(
recipients=[ADMIN_EMAIL],
subject=f"[ALERT] {title}",
message=message,
reference_doctype="CA Filing Request",
reference_name=self.name,
now=True,
)
except Exception as e:
frappe.log_error(
title="Filing failure email failed",
message=f"Could not send failure alert for {self.name}: {e}",
)
# 4. ToDo
frappe.get_doc({
"doctype": "ToDo",
"description": (
f"<b>BC Filing Failed</b> — {self.name}<br>"
f"Director: {self.director_first_name} {self.director_last_name}<br>"
f"Attempt: {(self.retry_count or 0) + 1}<br>"
f"Error: {(self.error_message or '')[:200]}<br>"
f"<b>Actions:</b> Retry, Amend, or Mark as Manual."
),
"priority": "High",
"allocated_to": "Administrator",
"reference_type": "CA Filing Request",
"reference_name": self.name,
}).insert(ignore_permissions=True)
def _alert_success(self):
"""Notify admin on successful filing."""
title = f"BC Filing COMPLETED — {self.name}"
message = (
f"<b>CA Filing Request:</b> {self.name}<br>"
f"<b>Incorporation Number:</b> {self.incorporation_number}<br>"
f"<b>Company Name:</b> {self.company_name_final}<br>"
f"<b>Government Fee:</b> ${self.government_fee_paid or 0:.2f}<br>"
f"<b>Sales Order:</b> {self.sales_order or 'N/A'}<br>"
f"<b>Attempts:</b> {(self.retry_count or 0) + 1}<br><br>"
f"<a href='/app/ca-filing-request/{self.name}'>View Filing Request →</a>"
)
_create_notification(
for_user="Administrator",
from_user="Administrator",
doc=self,
subject=title,
message=message,
type="Alert",
)
try:
frappe.sendmail(
recipients=[ADMIN_EMAIL],
subject=title,
message=message,
reference_doctype="CA Filing Request",
reference_name=self.name,
now=True,
)
except Exception:
pass
def _create_notification(for_user, from_user, doc, subject, message, type="Alert"):
"""Create an in-app Notification Log entry (the bell icon in ERPNext)."""
try:
notification = frappe.get_doc({
"doctype": "Notification Log",
"for_user": for_user,
"from_user": from_user,
"document_type": doc.doctype,
"document_name": doc.name,
"subject": subject,
"email_content": message,
"type": type,
})
notification.insert(ignore_permissions=True)
except Exception:
pass

View file

@ -0,0 +1,18 @@
import frappe
def after_install():
"""Post-install setup for Canadian Registry Services."""
# Ensure Playwright chromium is installed
import subprocess
try:
subprocess.run(
["playwright", "install", "chromium"],
capture_output=True,
timeout=120,
)
frappe.logger().info("Playwright Chromium installed for Canadian Registry")
except Exception as e:
frappe.logger().warning(f"Could not auto-install Playwright Chromium: {e}")
frappe.logger().info("Run manually: playwright install chromium")

View file

@ -0,0 +1 @@
Incorporation

View file

@ -0,0 +1,29 @@
"""
Province adapters for Canadian corporate registry automation.
Each province has its own portal, form structure, and filing process.
All adapters implement the same interface (BaseProvinceAdapter) so the
CA Filing Request DocType can dispatch to the correct one by province code.
Supported:
BC Corporate Online (COLIN) at corporateonline.gov.bc.ca
Incorporations, name reservations, trade names, annual reports.
Payment via Visa/MC/Amex (Relay virtual debit card from ERPNext vault).
Planned:
AB Alberta Corporate Registry
ON Ontario Business Registry
Federal Corporations Canada (CBCA)
"""
PROVINCE_ADAPTERS = {}
def register_adapter(province_code: str, adapter_class):
"""Register a province adapter."""
PROVINCE_ADAPTERS[province_code] = adapter_class
def get_adapter(province_code: str):
"""Get the adapter class for a province."""
return PROVINCE_ADAPTERS.get(province_code)

View file

@ -0,0 +1,4 @@
from frappe_ca_registry.provinces import register_adapter
from .adapter import BCAdapter
register_adapter("BC", BCAdapter)

View file

@ -0,0 +1,423 @@
"""
BC Corporate Online (COLIN) Playwright adapter.
Automates the 13-step incorporation form at corporateonline.gov.bc.ca.
Takes a CA Filing Request DocType as input, drives a headless browser
through all form steps, pays with the filing card from ERPNext vault,
and captures the incorporation number + certificate.
Usage (called by frappe_ca_registry.api.execute_filing):
adapter = BCAdapter()
result = await adapter.file_incorporation(filing_doc, card_data)
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
from playwright.sync_api import sync_playwright, Page
from . import selectors as S
from .config import INCORPORATOR, COMPLETING_PARTY, NOTIFICATION_EMAIL, DEFAULT_SHARES
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio")
MINIO_PORT = int(os.environ.get("MINIO_PORT", "9000"))
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "")
MINIO_SECURE = os.environ.get("MINIO_SECURE", "false").lower() == "true"
MINIO_BUCKET = "performancewest"
LOG = logging.getLogger("frappe_ca_registry.bc")
@dataclass
class FilingResult:
success: bool
incorporation_number: str = ""
company_name: str = ""
confirmation_number: str = ""
government_fee: float = 0.0
filed_at: Optional[str] = None
error: str = ""
screenshots: list[str] = None
def __post_init__(self):
if self.screenshots is None:
self.screenshots = []
class BCAdapter:
"""BC Corporate Online (COLIN) automation adapter."""
PROVINCE_CODE = "BC"
PORTAL_NAME = "Corporate Online (COLIN)"
def __init__(self, screenshot_dir: str = "/tmp/ca_registry_screenshots"):
self.screenshot_dir = Path(screenshot_dir)
self.screenshot_dir.mkdir(parents=True, exist_ok=True)
self._minio = None
def _get_minio(self):
"""Lazy-init MinIO client."""
if self._minio is None:
from minio import Minio
self._minio = Minio(
f"{MINIO_ENDPOINT}:{MINIO_PORT}",
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_SECURE,
)
return self._minio
def upload_screenshots_to_minio(
self, screenshots: list[str], order_number: str, filing_name: str
) -> list[str]:
"""
Upload all screenshots to MinIO under a private path and attach
them to the CA Filing Request in ERPNext as private files.
Returns list of MinIO object paths.
"""
minio = self._get_minio()
if not minio.bucket_exists(MINIO_BUCKET):
minio.make_bucket(MINIO_BUCKET)
minio_paths = []
for ss_path in screenshots:
p = Path(ss_path)
if not p.exists():
continue
# Store under: filings/{order_number}/screenshots/{filename}
remote = f"filings/{order_number}/screenshots/{p.name}"
minio.fput_object(MINIO_BUCKET, remote, str(p))
minio_paths.append(remote)
LOG.info("Uploaded screenshot: %s", remote)
# Attach to ERPNext CA Filing Request as private files
try:
import frappe
for remote in minio_paths:
frappe.get_doc({
"doctype": "File",
"file_name": Path(remote).name,
"file_url": f"/api/method/frappe_ca_registry.api.get_screenshot?path={remote}",
"attached_to_doctype": "CA Filing Request",
"attached_to_name": filing_name,
"is_private": 1, # Only System Manager can see
}).insert(ignore_permissions=True)
frappe.db.commit()
except Exception as e:
LOG.warning("Could not attach screenshots to ERPNext: %s", e)
# Clean up local files
for ss_path in screenshots:
try:
Path(ss_path).unlink(missing_ok=True)
except Exception:
pass
return minio_paths
def _ss(self, page: Page, name: str) -> str:
"""Take a screenshot and return the path."""
ts = datetime.now().strftime("%H%M%S")
path = self.screenshot_dir / f"bc_{name}_{ts}.png"
try:
page.screenshot(path=str(path), full_page=True)
except Exception:
pass
return str(path)
def _submit_form(self, page: Page):
"""Submit the COLIN form by injecting hidden nextButton fields."""
page.evaluate("""
var form = document.forms['transactionForm'] || document.forms[0];
var x = document.createElement('input');
x.type = 'hidden'; x.name = 'nextButton.x'; x.value = '1';
form.appendChild(x);
var y = document.createElement('input');
y.type = 'hidden'; y.name = 'nextButton.y'; y.value = '1';
form.appendChild(y);
form.submit();
""")
page.wait_for_load_state("networkidle", timeout=15000)
def _fill(self, page: Page, selector: str, value: str):
"""Fill a field if it exists."""
el = page.locator(selector)
if el.count() > 0 and el.first.is_visible():
el.first.fill(value)
def _select(self, page: Page, selector: str, value: str):
"""Select an option if the select exists."""
el = page.locator(selector)
if el.count() > 0 and el.first.is_visible():
try:
el.first.select_option(value)
except Exception:
LOG.warning("Could not select %s in %s", value, selector)
def _check(self, page: Page, selector: str):
"""Check a checkbox."""
el = page.locator(selector)
if el.count() > 0:
page.evaluate(f"""
var el = document.querySelector('{selector}');
if (el) {{ el.checked = true; el.dispatchEvent(new Event('change', {{bubbles:true}})); }}
""")
def _radio(self, page: Page, selector: str):
"""Click a radio button."""
page.evaluate(f"""
var el = document.querySelector('{selector}');
if (el) {{ el.checked = true; el.dispatchEvent(new Event('change', {{bubbles:true}})); }}
""")
def file_incorporation(self, filing: dict, card_data: dict) -> FilingResult:
"""
Execute a full BC incorporation filing on COLIN.
Args:
filing: Dict of CA Filing Request fields
card_data: Decrypted card data from ERPNext Sensitive ID
{"number", "exp_month", "exp_year", "cvv", "name",
"address_line1", "city", "state", "zip"}
Returns:
FilingResult with incorporation number on success
"""
result = FilingResult(success=False)
company_type = filing.get("company_type", "Numbered")
is_numbered = company_type in ("Numbered", "Numbered + Trade Name")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
)
page = browser.new_page(
viewport={"width": 1440, "height": 1200},
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
)
try:
# ── Overview page ────────────────────────────
LOG.info("[BC] Loading COLIN incorporation form...")
page.goto(S.INCORP_URL, wait_until="networkidle", timeout=30000)
result.screenshots.append(self._ss(page, "00_overview"))
self._check(page, S.CERTIFY_CHECKBOX)
time.sleep(0.5)
self._submit_form(page)
# ── Step 1: Initial Information ──────────────
LOG.info("[BC] Step 1: Initial information")
result.screenshots.append(self._ss(page, "01_initial"))
if is_numbered:
self._radio(page, S.NAME_TYPE_NUMBERED)
else:
self._radio(page, S.NAME_TYPE_RESERVED)
nr = filing.get("name_reservation_number", "")
self._fill(page, S.NAME_RESERVATION_NUMBER, nr)
self._radio(page, S.EFFECTIVE_NOW)
time.sleep(0.3)
self._submit_form(page)
# ── Step 2: Confirm Filing ────────────────────
LOG.info("[BC] Step 2: Confirm filing")
result.screenshots.append(self._ss(page, "02_confirm"))
self._submit_form(page)
# ── Step 3: Incorporator Info ─────────────────
# Incorporator = Justin Hannah (PW principal), not the customer
LOG.info("[BC] Step 3: Incorporator info (Justin Hannah)")
result.screenshots.append(self._ss(page, "03_incorporator"))
self._fill(page, S.INCORP_FIRST_NAME, INCORPORATOR["first_name"])
self._fill(page, S.INCORP_LAST_NAME, INCORPORATOR["last_name"])
self._fill(page, S.INCORP_ADDRESS1, INCORPORATOR["address"])
self._fill(page, S.INCORP_ADDRESS2, INCORPORATOR.get("address2", ""))
self._fill(page, S.INCORP_CITY, INCORPORATOR["city"])
self._fill(page, S.INCORP_POSTAL, INCORPORATOR["postal"])
self._select(page, S.INCORP_COUNTRY, INCORPORATOR["country"])
self._select(page, S.INCORP_PROVINCE, INCORPORATOR["province"])
time.sleep(0.3)
self._submit_form(page)
# ── Step 4: Completing Party ──────────────────
# Completing party = same as incorporator (Justin Hannah / PW)
LOG.info("[BC] Step 4: Completing party (Justin Hannah / PW)")
result.screenshots.append(self._ss(page, "04_completing"))
self._fill(page, S.CP_FIRST_NAME, COMPLETING_PARTY["first_name"])
self._fill(page, S.CP_LAST_NAME, COMPLETING_PARTY["last_name"])
self._fill(page, S.CP_ADDRESS1, COMPLETING_PARTY["address"])
self._fill(page, S.CP_CITY, COMPLETING_PARTY["city"])
self._fill(page, S.CP_POSTAL, COMPLETING_PARTY["postal"])
self._select(page, S.CP_COUNTRY, COMPLETING_PARTY["country"])
self._select(page, S.CP_PROVINCE, COMPLETING_PARTY["province"])
self._fill(page, S.CP_PHONE, COMPLETING_PARTY["phone"])
self._fill(page, S.CP_EMAIL, COMPLETING_PARTY["email"])
time.sleep(0.3)
self._submit_form(page)
# ── Step 5: Translated Name ───────────────────
LOG.info("[BC] Step 5: Translated name (skip)")
result.screenshots.append(self._ss(page, "05_translated"))
self._submit_form(page)
# ── Step 6: Director Info ─────────────────────
# Director = the customer (from the order form)
LOG.info("[BC] Step 6: Director info (customer)")
result.screenshots.append(self._ss(page, "06_director"))
self._fill(page, S.DIR_FIRST_NAME, filing.get("director_first_name", ""))
self._fill(page, S.DIR_LAST_NAME, filing.get("director_last_name", ""))
self._fill(page, S.DIR_MIDDLE_NAME, filing.get("director_middle_name", ""))
self._fill(page, S.DIR_ADDRESS1, filing.get("director_address", ""))
self._fill(page, S.DIR_ADDRESS2, filing.get("director_address2", ""))
self._fill(page, S.DIR_CITY, filing.get("director_city", ""))
self._fill(page, S.DIR_POSTAL, filing.get("director_postal", ""))
self._select(page, S.DIR_COUNTRY, filing.get("director_country", "US"))
self._select(page, S.DIR_PROVINCE, filing.get("director_province", ""))
# Mailing address
if filing.get("director_mailing_different"):
self._fill(page, S.DIR_MAIL_ADDRESS1, filing.get("director_mailing_street", ""))
self._fill(page, S.DIR_MAIL_CITY, filing.get("director_mailing_city", ""))
self._fill(page, S.DIR_MAIL_POSTAL, filing.get("director_mailing_postal", ""))
self._select(page, S.DIR_MAIL_COUNTRY, filing.get("director_mailing_country", "US"))
self._select(page, S.DIR_MAIL_PROVINCE, filing.get("director_mailing_province", ""))
else:
self._check(page, S.DIR_MAIL_SAME)
time.sleep(0.3)
self._submit_form(page)
# ── Step 7: Office Addresses ──────────────────
LOG.info("[BC] Step 7: Office addresses")
result.screenshots.append(self._ss(page, "07_offices"))
self._fill(page, S.REG_OFFICE_ADDRESS1, filing.get("office_address", "329 Howe St"))
self._fill(page, S.REG_OFFICE_CITY, filing.get("office_city", "Vancouver"))
self._fill(page, S.REG_OFFICE_POSTAL, filing.get("office_postal", "V6C 3N2"))
self._select(page, S.REG_OFFICE_COUNTRY, "CA")
self._select(page, S.REG_OFFICE_PROVINCE, "BC")
# Records office same as registered
self._check(page, S.RECORDS_SAME_AS_REG)
time.sleep(0.3)
self._submit_form(page)
# ── Step 8: Share Structure ───────────────────
LOG.info("[BC] Step 8: Share structure")
result.screenshots.append(self._ss(page, "08_shares"))
self._fill(page, S.SHARE_CLASS_NAME, filing.get("share_class", DEFAULT_SHARES["class_name"]))
self._fill(page, S.SHARE_MAX_SHARES, str(filing.get("shares_authorized", DEFAULT_SHARES["max_shares"])))
# No par value by default
time.sleep(0.3)
self._submit_form(page)
# ── Step 9: Notification ──────────────────────
# BC Registry certificate goes to PW, not the customer
LOG.info("[BC] Step 9: Notification email (PW)")
result.screenshots.append(self._ss(page, "09_notification"))
self._fill(page, S.NOTIFY_EMAIL, NOTIFICATION_EMAIL)
self._fill(page, S.NOTIFY_CONFIRM_EMAIL, NOTIFICATION_EMAIL)
time.sleep(0.3)
self._submit_form(page)
# ── Step 10: Company Information (read-only) ──
LOG.info("[BC] Step 10: Company info review")
result.screenshots.append(self._ss(page, "10_company_info"))
self._submit_form(page)
# ── Step 11: Confirm ──────────────────────────
LOG.info("[BC] Step 11: Confirm company info")
result.screenshots.append(self._ss(page, "11_confirm"))
self._check(page, S.CONFIRM_CHECKBOX)
time.sleep(0.3)
self._submit_form(page)
# ── Step 12: Payment ──────────────────────────
LOG.info("[BC] Step 12: Payment")
result.screenshots.append(self._ss(page, "12_payment"))
self._fill(page, S.PAY_CARD_NUMBER, card_data.get("number", ""))
self._select(page, S.PAY_CARD_EXPIRY_MONTH, card_data.get("exp_month", ""))
self._select(page, S.PAY_CARD_EXPIRY_YEAR, card_data.get("exp_year", ""))
self._fill(page, S.PAY_CARD_CVV, card_data.get("cvv", ""))
self._fill(page, S.PAY_CARD_NAME, card_data.get("name", ""))
self._fill(page, S.PAY_BILLING_ADDRESS, card_data.get("address_line1", ""))
self._fill(page, S.PAY_BILLING_CITY, card_data.get("city", ""))
self._select(page, S.PAY_BILLING_PROVINCE, card_data.get("state", ""))
self._fill(page, S.PAY_BILLING_POSTAL, card_data.get("zip", ""))
self._select(page, S.PAY_BILLING_COUNTRY, "US")
result.screenshots.append(self._ss(page, "12b_payment_filled"))
# Submit payment
LOG.info("[BC] Submitting payment...")
self._submit_form(page)
time.sleep(3) # Wait for payment processing
# ── Step 13: Receipt ──────────────────────────
LOG.info("[BC] Step 13: Receipt")
result.screenshots.append(self._ss(page, "13_receipt"))
body = page.inner_text("body")
# Extract incorporation number from receipt page
# COLIN shows it in the receipt table
import re
inc_match = re.search(r'(?:Incorporation\s*(?:Number|No\.?|#)\s*[:.]?\s*)(\d{5,8})', body, re.IGNORECASE)
if inc_match:
result.incorporation_number = inc_match.group(1)
result.success = True
LOG.info("[BC] Incorporation number: %s", result.incorporation_number)
# Company name
name_match = re.search(r'(\d{5,8}\s+B\.?C\.?\s+Ltd\.?)', body, re.IGNORECASE)
if name_match:
result.company_name = name_match.group(1)
# Confirmation number
conf_match = re.search(r'(?:Confirmation\s*(?:Number|No\.?|#)\s*[:.]?\s*)(\w+)', body, re.IGNORECASE)
if conf_match:
result.confirmation_number = conf_match.group(1)
result.government_fee = 350.00 # Standard BC incorporation fee
result.filed_at = datetime.utcnow().isoformat()
if not result.success:
# Check for error messages
errors = page.locator(".error, .errorMessage, font[color='red']")
if errors.count() > 0:
result.error = errors.first.inner_text().strip()[:500]
else:
result.error = f"Could not extract incorporation number from receipt page. Body: {body[:300]}"
except Exception as exc:
LOG.error("[BC] Filing failed: %s", exc)
result.error = str(exc)[:500]
try:
result.screenshots.append(self._ss(page, "ERROR"))
except Exception:
pass
finally:
browser.close()
return result

View file

@ -0,0 +1,50 @@
"""
BC filing configuration completing party (Performance West) details
and default values for BC Corporate Online (COLIN).
"""
# Justin Hannah is both the incorporator and completing party for all filings.
# As the principal of Performance West, he signs the Incorporation Agreement
# and submits the filing on behalf of the customer's new BC corporation.
INCORPORATOR = {
"first_name": "Justin",
"last_name": "Hannah",
"address": "525 Randall Avenue",
"address2": "100-1195",
"city": "Cheyenne",
"province": "WY",
"postal": "82001",
"country": "US",
"phone": "8884110383",
"email": "filings@performancewest.net",
}
# Completing party = same as incorporator (Justin Hannah / Performance West)
COMPLETING_PARTY = INCORPORATOR
# BC Registry sends the Certificate of Incorporation to this email.
# This should be PW's email, not the customer's — we control certificate delivery.
NOTIFICATION_EMAIL = "filings@performancewest.net"
# Default registered office (Anytime Mailbox — 329 Howe St, Vancouver)
DEFAULT_REGISTERED_OFFICE = {
"address": "329 Howe St",
"city": "Vancouver",
"province": "BC",
"postal": "V6C 3N2",
"country": "CA",
}
# Default share structure for telecom corporations
DEFAULT_SHARES = {
"class_name": "Common",
"max_shares": "10000",
"has_par_value": False,
"currency": "CAD",
"has_special_rights": False,
}
# Filing fee (CAD)
INCORPORATION_FEE_CAD = 350.00
TRADE_NAME_FEE_CAD = 40.00
FUTURE_EFFECTIVE_FEE_CAD = 100.00

View file

@ -0,0 +1,141 @@
"""
COLIN (BC Corporate Online) form selectors.
Mapped from live portal inspection on 2026-03-30.
Portal: https://www.corporateonline.gov.bc.ca
COLIN uses old-school HTML forms with image buttons (type="image")
and standard form field names. No JavaScript frameworks, no iframes,
no CAPTCHAs. Authentication is not required for one-time incorporation
filings the form is publicly accessible.
"""
# ── Entry point ──────────────────────────────────────────────
INCORP_URL = (
"https://www.corporateonline.gov.bc.ca/corporateonline/colin/"
"accesstransaction/menu.do?action=overview&filingTypeCode=ICORP&from=main"
)
# ── Overview page (certification) ────────────────────────────
CERTIFY_CHECKBOX = "#certifyCheck"
# ── Navigation (all pages) ───────────────────────────────────
# COLIN uses image-type submit buttons — click via form submit with hidden fields
NEXT_BUTTON = "input[name='nextButton']"
BACK_BUTTON = "input[name='previousButton']"
FORM_NAME = "transactionForm"
# ── Step 1: Initial Information ──────────────────────────────
# Company name type (radio)
NAME_TYPE_RESERVED = "input[name='identCorpName.nameType'][value='RESERVED']"
NAME_TYPE_NUMBERED = "input[name='identCorpName.nameType'][value='NUMBER']"
NAME_RESERVATION_NUMBER = "input[name='identCorpName.nameReservNum']"
# Effective date (radio)
EFFECTIVE_NOW = "input[name='fedDto.fileAndEffectiveNowString'][value='Y']"
EFFECTIVE_FUTURE = "input[name='fedDto.fileAndEffectiveNowString'][value='N']"
EFFECTIVE_MONTH = "select[name='fedDto.effectiveDateTime.month']"
EFFECTIVE_DAY = "select[name='fedDto.effectiveDateTime.day']"
EFFECTIVE_YEAR = "input[name='fedDto.effectiveDateTime.year']"
EFFECTIVE_HOUR = "select[name='fedDto.effectiveDateTime.hour']"
EFFECTIVE_MINUTE = "select[name='fedDto.effectiveDateTime.minute']"
EFFECTIVE_AM = "input[name='fedDto.effectiveDateTime.amPm'][value='AM']"
EFFECTIVE_PM = "input[name='fedDto.effectiveDateTime.amPm'][value='PM']"
# ── Step 2: Confirm Filing ───────────────────────────────────
# (Review-only page — no fields to fill, just click Next)
# ── Step 3: Incorporator Info ────────────────────────────────
INCORP_FIRST_NAME = "input[name='incorporatorPageDto.incorporator.firstName']"
INCORP_LAST_NAME = "input[name='incorporatorPageDto.incorporator.lastName']"
INCORP_MIDDLE_NAME = "input[name='incorporatorPageDto.incorporator.middleName']"
INCORP_ADDRESS1 = "input[name='incorporatorPageDto.incorporator.addressLine1']"
INCORP_ADDRESS2 = "input[name='incorporatorPageDto.incorporator.addressLine2']"
INCORP_CITY = "input[name='incorporatorPageDto.incorporator.city']"
INCORP_PROVINCE = "select[name='incorporatorPageDto.incorporator.province']"
INCORP_POSTAL = "input[name='incorporatorPageDto.incorporator.postalCode']"
INCORP_COUNTRY = "select[name='incorporatorPageDto.incorporator.country']"
# ── Step 4: Completing Party ─────────────────────────────────
# "Same as incorporator" checkbox or separate fields
CP_SAME_AS_INCORP = "input[name='completingPartyPageDto.sameAsIncorporator']"
CP_FIRST_NAME = "input[name='completingPartyPageDto.completingParty.firstName']"
CP_LAST_NAME = "input[name='completingPartyPageDto.completingParty.lastName']"
CP_ADDRESS1 = "input[name='completingPartyPageDto.completingParty.addressLine1']"
CP_CITY = "input[name='completingPartyPageDto.completingParty.city']"
CP_PROVINCE = "select[name='completingPartyPageDto.completingParty.province']"
CP_POSTAL = "input[name='completingPartyPageDto.completingParty.postalCode']"
CP_COUNTRY = "select[name='completingPartyPageDto.completingParty.country']"
CP_PHONE = "input[name='completingPartyPageDto.completingParty.phone']"
CP_EMAIL = "input[name='completingPartyPageDto.completingParty.email']"
# ── Step 5: Translated Name ─────────────────────────────────
# (Usually skipped — no fields required for English-only names)
# ── Step 6: Director Info ────────────────────────────────────
DIR_FIRST_NAME = "input[name='directorPageDto.director.firstName']"
DIR_LAST_NAME = "input[name='directorPageDto.director.lastName']"
DIR_MIDDLE_NAME = "input[name='directorPageDto.director.middleName']"
DIR_ADDRESS1 = "input[name='directorPageDto.director.addressLine1']"
DIR_ADDRESS2 = "input[name='directorPageDto.director.addressLine2']"
DIR_CITY = "input[name='directorPageDto.director.city']"
DIR_PROVINCE = "select[name='directorPageDto.director.province']"
DIR_POSTAL = "input[name='directorPageDto.director.postalCode']"
DIR_COUNTRY = "select[name='directorPageDto.director.country']"
# Mailing address (if different from delivery)
DIR_MAIL_SAME = "input[name='directorPageDto.director.mailingAddressSame']"
DIR_MAIL_ADDRESS1 = "input[name='directorPageDto.director.mailingAddressLine1']"
DIR_MAIL_CITY = "input[name='directorPageDto.director.mailingCity']"
DIR_MAIL_PROVINCE = "select[name='directorPageDto.director.mailingProvince']"
DIR_MAIL_POSTAL = "input[name='directorPageDto.director.mailingPostalCode']"
DIR_MAIL_COUNTRY = "select[name='directorPageDto.director.mailingCountry']"
# ── Step 7: Office Addresses ─────────────────────────────────
# Registered office (must be in BC)
REG_OFFICE_ADDRESS1 = "input[name='officeAddressPageDto.registeredOffice.addressLine1']"
REG_OFFICE_ADDRESS2 = "input[name='officeAddressPageDto.registeredOffice.addressLine2']"
REG_OFFICE_CITY = "input[name='officeAddressPageDto.registeredOffice.city']"
REG_OFFICE_PROVINCE = "select[name='officeAddressPageDto.registeredOffice.province']"
REG_OFFICE_POSTAL = "input[name='officeAddressPageDto.registeredOffice.postalCode']"
REG_OFFICE_COUNTRY = "select[name='officeAddressPageDto.registeredOffice.country']"
# Records office (checkbox if same as registered)
RECORDS_SAME_AS_REG = "input[name='officeAddressPageDto.recordsOfficeSameAsRegistered']"
# ── Step 8: Share Structure ──────────────────────────────────
# Default: 1 class of common shares, no par value, unlimited
SHARE_CLASS_NAME = "input[name='shareStructurePageDto.shareClass.className']"
SHARE_MAX_SHARES = "input[name='shareStructurePageDto.shareClass.maxShares']"
SHARE_PAR_VALUE = "input[name='shareStructurePageDto.shareClass.parValue']"
SHARE_HAS_PAR = "input[name='shareStructurePageDto.shareClass.hasParValue']"
SHARE_CURRENCY = "select[name='shareStructurePageDto.shareClass.currency']"
# Special rights checkbox
SHARE_SPECIAL_RIGHTS = "input[name='shareStructurePageDto.shareClass.hasSpecialRights']"
# ── Step 9: Notification ─────────────────────────────────────
NOTIFY_EMAIL = "input[name='notificationPageDto.email']"
NOTIFY_CONFIRM_EMAIL = "input[name='notificationPageDto.confirmEmail']"
# ── Step 10: Company Information ─────────────────────────────
# (Read-only summary — no fields)
# ── Step 11: Confirm Company Information ─────────────────────
CONFIRM_CHECKBOX = "input[name='confirmCompanyPageDto.confirmCheck']"
# ── Step 12: Ready to Pay ────────────────────────────────────
# Credit card payment form
PAY_CARD_NUMBER = "input[name='paymentPageDto.cardNumber']"
PAY_CARD_EXPIRY_MONTH = "select[name='paymentPageDto.expiryMonth']"
PAY_CARD_EXPIRY_YEAR = "select[name='paymentPageDto.expiryYear']"
PAY_CARD_CVV = "input[name='paymentPageDto.cvv']"
PAY_CARD_NAME = "input[name='paymentPageDto.cardholderName']"
PAY_BILLING_ADDRESS = "input[name='paymentPageDto.billingAddress']"
PAY_BILLING_CITY = "input[name='paymentPageDto.billingCity']"
PAY_BILLING_PROVINCE = "select[name='paymentPageDto.billingProvince']"
PAY_BILLING_POSTAL = "input[name='paymentPageDto.billingPostalCode']"
PAY_BILLING_COUNTRY = "select[name='paymentPageDto.billingCountry']"
PAY_SUBMIT = "input[name='payButton']"
# ── Step 13: Receipt ─────────────────────────────────────────
RECEIPT_INCORP_NUMBER = ".incorporationNumber, td:has-text('Incorporation Number') + td"
RECEIPT_COMPANY_NAME = ".companyName, td:has-text('Company Name') + td"
RECEIPT_CONFIRMATION = ".confirmationNumber, td:has-text('Confirmation') + td"

View file

@ -0,0 +1,18 @@
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "frappe_ca_registry"
version = "1.0.0"
description = "Canadian corporate registry automation for Frappe/ERPNext — incorporations, name reservations, trade names, annual reports. BC (COLIN) first, other provinces to follow."
license = { text = "MIT" }
authors = [{ name = "Performance West Inc.", email = "support@performancewest.net" }]
requires-python = ">=3.10"
dependencies = [
"frappe>=15.0.0,<16",
"playwright",
]
[project.urls]
Repository = "https://github.com/performancewest/frappe_ca_registry"

View file

@ -0,0 +1,10 @@
from setuptools import setup, find_packages
setup(
name="frappe_ca_registry",
version="1.0.0",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=["frappe", "playwright"],
)