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:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
1
frappe_ca_registry/frappe_ca_registry/__init__.py
Normal file
1
frappe_ca_registry/frappe_ca_registry/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "1.0.0"
|
||||
230
frappe_ca_registry/frappe_ca_registry/api.py
Normal file
230
frappe_ca_registry/frappe_ca_registry/api.py
Normal 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()
|
||||
26
frappe_ca_registry/frappe_ca_registry/hooks.py
Normal file
26
frappe_ca_registry/frappe_ca_registry/hooks.py
Normal 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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -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>`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
18
frappe_ca_registry/frappe_ca_registry/install.py
Normal file
18
frappe_ca_registry/frappe_ca_registry/install.py
Normal 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")
|
||||
1
frappe_ca_registry/frappe_ca_registry/modules.txt
Normal file
1
frappe_ca_registry/frappe_ca_registry/modules.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Incorporation
|
||||
29
frappe_ca_registry/frappe_ca_registry/provinces/__init__.py
Normal file
29
frappe_ca_registry/frappe_ca_registry/provinces/__init__.py
Normal 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)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from frappe_ca_registry.provinces import register_adapter
|
||||
from .adapter import BCAdapter
|
||||
|
||||
register_adapter("BC", BCAdapter)
|
||||
423
frappe_ca_registry/frappe_ca_registry/provinces/bc/adapter.py
Normal file
423
frappe_ca_registry/frappe_ca_registry/provinces/bc/adapter.py
Normal 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
|
||||
50
frappe_ca_registry/frappe_ca_registry/provinces/bc/config.py
Normal file
50
frappe_ca_registry/frappe_ca_registry/provinces/bc/config.py
Normal 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
|
||||
141
frappe_ca_registry/frappe_ca_registry/provinces/bc/selectors.py
Normal file
141
frappe_ca_registry/frappe_ca_registry/provinces/bc/selectors.py
Normal 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"
|
||||
18
frappe_ca_registry/pyproject.toml
Normal file
18
frappe_ca_registry/pyproject.toml
Normal 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"
|
||||
10
frappe_ca_registry/setup.py
Normal file
10
frappe_ca_registry/setup.py
Normal 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"],
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue