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,19 @@
# Performance West ERPNext
Custom payment gateways, surcharge hooks, and identity verification for Performance West Inc.
## Features
- **PW Stripe Settings** — Stripe Checkout Sessions gateway (Card+Klarna, ACH)
- **Surcharge hooks** — Injects payment processing fee line items on Sales Invoices
- **Identity gate** — Blocks CRTC orders without verified identity
- **Custom fields** — Sales Order, Sales Invoice, Payment Request extensions
## Installation
```bash
bench get-app performancewest_erpnext https://github.com/performancewest/performancewest_erpnext
bench --site your-site.com install-app performancewest_erpnext
```
Requires: `frappe>=15`, `erpnext>=15`, `payments`

View file

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

View file

@ -0,0 +1,300 @@
"""
performancewest_erpnext.api Public/whitelisted API endpoints.
Endpoints:
- get_payment_request_status poll Payment Request + Stripe session status
- stripe_webhook receive Stripe webhook events (checkout.session.*)
"""
import json
import frappe
from frappe import _
@frappe.whitelist(allow_guest=True)
def get_payment_request_status(payment_request_name: str) -> dict:
"""
Poll the status of a Payment Request, optionally syncing from Stripe.
Returns:
{"status": "Paid"|"Initiated"|"Pending"|..., "stripe_session_id": str|None}
"""
if not payment_request_name:
frappe.throw(_("payment_request_name is required"), frappe.ValidationError)
try:
pr = frappe.get_doc("Payment Request", payment_request_name)
except frappe.DoesNotExistError:
frappe.throw(_("Payment Request not found"), frappe.DoesNotExistError)
session_id = pr.get("custom_stripe_session_id")
stripe_status = None
# If not yet paid, check Stripe directly for fresh status
if pr.status not in ("Paid", "Cancelled") and session_id:
try:
gateway_account = frappe.get_doc(
"Payment Gateway Account", pr.payment_gateway_account
)
stripe_settings = frappe.get_doc(
"PW Stripe Settings", gateway_account.gateway_settings
)
session_data = stripe_settings.get_session_status(session_id)
stripe_status = session_data.get("status")
# Auto-reconcile if Stripe reports paid but ERPNext hasn't updated yet
if stripe_status == "paid" and pr.status != "Paid":
frappe.logger().info(
f"[get_payment_request_status] Stripe reports paid for {payment_request_name}; "
"reconciliation should happen via webhook."
)
except Exception as e:
frappe.log_error(str(e), "get_payment_request_status: Stripe lookup failed")
return {
"status": pr.status,
"stripe_session_id": session_id,
"stripe_status": stripe_status,
"reference_doctype": pr.reference_doctype,
"reference_name": pr.reference_name,
}
@frappe.whitelist(allow_guest=True)
def stripe_webhook():
"""
Receive Stripe webhook events for PW Stripe Checkout Sessions.
Events handled:
checkout.session.completed create Payment Entry, mark Payment Request Paid
checkout.session.expired mark Payment Request as expired/cancelled
Stripe sends:
POST /api/method/performancewest_erpnext.api.stripe_webhook
Stripe-Signature: t=...,v1=...
"""
# Get raw payload for signature verification
payload = frappe.request.data
sig_header = frappe.request.headers.get("Stripe-Signature", "")
if not payload:
frappe.throw(_("Empty webhook payload"), frappe.ValidationError)
try:
body = json.loads(payload)
except json.JSONDecodeError:
frappe.throw(_("Invalid JSON in webhook payload"), frappe.ValidationError)
# Extract metadata to find gateway settings
session = body.get("data", {}).get("object", {})
metadata = session.get("metadata", {})
payment_request_name = metadata.get("payment_request")
if not payment_request_name:
# Can't route without payment_request in metadata — still return 200
return {"received": True, "status": "ignored", "reason": "no payment_request in metadata"}
# Determine which PW Stripe Settings instance handles this
# Look up via the Payment Request's gateway account
try:
pr = frappe.get_doc("Payment Request", payment_request_name)
gateway_account = frappe.get_doc("Payment Gateway Account", pr.payment_gateway_account)
stripe_settings = frappe.get_doc("PW Stripe Settings", gateway_account.gateway_settings)
except Exception as e:
frappe.log_error(str(e), "stripe_webhook: could not find gateway settings")
return {"received": True, "status": "error", "reason": str(e)}
# Verify signature and parse event
try:
event = stripe_settings.handle_webhook(payload, sig_header)
except ValueError as e:
frappe.throw(str(e), frappe.AuthenticationError)
if not event:
return {"received": True, "status": "ignored"}
if event["event_type"] == "payment.succeeded":
_handle_payment_succeeded(payment_request_name, event)
elif event["event_type"] == "payment.expired":
_handle_payment_expired(payment_request_name)
return {"received": True, "status": "processed", "event_type": event["event_type"]}
def _handle_payment_succeeded(payment_request_name: str, event: dict):
"""Mark Payment Request as Paid and create Payment Entry."""
try:
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_entry
pr = frappe.get_doc("Payment Request", payment_request_name)
if pr.status == "Paid":
return # Idempotent
# Create Payment Entry via ERPNext standard method
payment_entry = make_payment_entry(pr.name)
payment_entry.reference_no = event.get("payment_intent", event.get("session_id", ""))
payment_entry.reference_date = frappe.utils.nowdate()
payment_entry.remarks = f"Stripe Checkout Session: {event.get('session_id', '')}"
payment_entry.flags.ignore_permissions = True
payment_entry.submit()
# Mark Payment Request as Paid
frappe.db.set_value("Payment Request", payment_request_name, "status", "Paid")
frappe.db.commit()
frappe.logger().info(
f"[stripe_webhook] Payment succeeded for {payment_request_name}"
f"Payment Entry: {payment_entry.name}"
)
except Exception as e:
frappe.log_error(
f"[stripe_webhook] Failed to process payment success for {payment_request_name}: {e}",
"Stripe Webhook Payment Error",
)
def _handle_payment_expired(payment_request_name: str):
"""Mark Payment Request as expired/cancelled."""
try:
frappe.db.set_value("Payment Request", payment_request_name, "status", "Cancelled")
frappe.db.commit()
except Exception as e:
frappe.log_error(str(e), "stripe_webhook: payment expired handler error")
@frappe.whitelist()
def get_filing_card(card_name: str) -> dict:
"""
Return decrypted filing card details from Sensitive ID.
This is the only way to read Password-type fields via the API
Frappe's standard REST API masks Password fields with ********.
Requires authenticated API call (ERPNEXT_API_KEY:ERPNEXT_API_SECRET).
Only returns DEBIT_CARD type records. Never returns SSN/EIN/ITIN.
Args:
card_name: Sensitive ID record name (e.g. "relay-filing-card")
Returns:
{"number": "...", "exp_month": "...", "exp_year": "...",
"cvv": "...", "name": "...", "zip": "..."}
"""
if not card_name:
frappe.throw(_("card_name is required"), frappe.ValidationError)
try:
doc = frappe.get_doc("Sensitive ID", card_name)
except frappe.DoesNotExistError:
frappe.throw(_("Filing card not found"), frappe.DoesNotExistError)
if doc.id_type != "DEBIT_CARD":
frappe.throw(
_("This endpoint only returns DEBIT_CARD records"),
frappe.PermissionError,
)
# Read from individual Password-type fields (card_number, card_cvv)
# and plain Data fields (card_exp_month, card_name, card_zip, etc.)
card_number = doc.get_password("card_number") or ""
card_cvv = doc.get_password("card_cvv") or ""
if not card_number:
# Fallback: try the legacy JSON blob in encrypted_value
encrypted_value = doc.get_password("encrypted_value")
if encrypted_value:
try:
return json.loads(encrypted_value)
except (json.JSONDecodeError, TypeError):
pass
frappe.throw(_("Card has no number stored"), frappe.ValidationError)
return {
"number": card_number,
"exp_month": doc.card_exp_month or "",
"exp_year": doc.card_exp_year or "",
"cvv": card_cvv,
"name": doc.card_name or "Performance West Inc",
"zip": doc.card_zip or "",
"address_line1": doc.card_address_line1 or "",
"address_line2": doc.card_address_line2 or "",
"city": doc.card_city or "",
"state": doc.card_state or "",
}
@frappe.whitelist()
def update_identity_status(order_name: str, status: str, session_id: str = "") -> dict:
"""
Update identity verification status on a Sales Order.
Called by Express API after Stripe Identity webhook completes verification.
Requires authenticated API call (ERPNEXT_API_KEY:ERPNEXT_API_SECRET).
Args:
order_name: ERPNext Sales Order name (e.g. "SAL-ORD-2026-00001")
status: "Verified" | "Failed" | "Needs Review" | "Pending"
session_id: Stripe Identity Verification Session ID (optional)
Returns:
dict with success, order, and status keys
"""
from performancewest_erpnext.payments.identity_gate import update_identity_status as _update
_update(order_name, status, session_id)
return {"success": True, "order": order_name, "status": status}
# ─── MinIO pre-signed URL generator (admin dashboards) ────────────────────
@frappe.whitelist()
def presign_minio(key: str, expires: int = 3600) -> dict:
"""Generate a short-lived pre-signed GET URL for a MinIO object.
Used by the admin-filings dashboard to surface packet downloads to
reviewers without requiring direct MinIO credentials. Whitelisted
for logged-in users only (no ``allow_guest``); the admin-filings
page is itself role-gated so callers are already trusted.
Args:
key: MinIO object key (possibly prefixed with "bucket/...").
expires: URL lifetime in seconds (max 24h).
Returns:
``{"url": "...", "expires_in": 3600}``
"""
import os
if not key:
frappe.throw(_("key is required"), frappe.ValidationError)
expires = max(60, min(int(expires or 3600), 24 * 3600))
# Admin-only — require the user to be in one of the approver roles.
user_roles = set(frappe.get_roles(frappe.session.user) or [])
if not (user_roles & {"Accounting Advisor", "System Manager"}):
frappe.throw(
_("Presigning MinIO URLs requires Accounting Advisor or System Manager role."),
frappe.PermissionError,
)
try:
from minio import Minio
from datetime import timedelta
except ImportError:
frappe.throw(_("minio package not installed on the Frappe bench"),
frappe.ValidationError)
endpoint = os.environ.get("MINIO_ENDPOINT", "minio:9000")
access_key = os.environ.get("MINIO_ACCESS_KEY", "")
secret_key = os.environ.get("MINIO_SECRET_KEY", "")
bucket = os.environ.get("MINIO_BUCKET", "performancewest")
secure = os.environ.get("MINIO_SECURE", "false").lower() == "true"
# Strip leading bucket/ prefix if the caller included it
obj_key = key.lstrip("/")
if obj_key.startswith(bucket + "/"):
obj_key = obj_key[len(bucket) + 1:]
client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
url = client.presigned_get_object(bucket, obj_key, expires=timedelta(seconds=expires))
return {"url": url, "expires_in": expires}

View file

@ -0,0 +1,102 @@
{
"actions": [],
"creation": "2026-04-16 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 0,
"engine": "InnoDB",
"field_order": [
"auto_filing_section",
"auto_filing_enabled",
"admin_email",
"auto_filing_description",
"forfeiture_section",
"rmd_false_info_forfeiture_cents",
"rmd_late_update_forfeiture_cents",
"cpni_per_violation_max_cents",
"cpni_total_max_cents"
],
"fields": [
{
"fieldname": "auto_filing_section",
"fieldtype": "Section Break",
"label": "Auto-Filing"
},
{
"default": "0",
"fieldname": "auto_filing_enabled",
"fieldtype": "Check",
"label": "Auto-Filing Enabled",
"description": "When OFF (default), FCC/USAC filing handlers stage the packet for admin review instead of submitting. Admin must click Approve & File on each order."
},
{
"default": "ops@performancewest.net",
"fieldname": "admin_email",
"fieldtype": "Data",
"label": "Admin Review Email",
"description": "Who receives the review-and-approve email + ToDo when auto-filing is disabled"
},
{
"fieldname": "auto_filing_description",
"fieldtype": "HTML",
"options": "<div style=\"background:#fef3c7;border:1px solid #fbbf24;padding:12px;border-radius:4px;\"><strong>Safety default:</strong> Auto-filing is OFF. Each filing handler will generate the packet, store it in MinIO, and create a ToDo + admin email. Admin reviews the packet, then clicks Approve &amp; File to submit. Flip this on only after you are confident in the Playwright flows against the live FCC/USAC/BDC portals.</div>"
},
{
"fieldname": "forfeiture_section",
"fieldtype": "Section Break",
"label": "2026 Forfeiture Reference (display-only)"
},
{
"default": "1000000",
"fieldname": "rmd_false_info_forfeiture_cents",
"fieldtype": "Int",
"label": "RMD false/inaccurate info (cents)",
"description": "$10,000 base per 2025 RMD R&O effective Feb 5, 2026"
},
{
"default": "100000",
"fieldname": "rmd_late_update_forfeiture_cents",
"fieldtype": "Int",
"label": "RMD late update (cents/day)",
"description": "$1,000 base per day after 10 business days"
},
{
"default": "25132200",
"fieldname": "cpni_per_violation_max_cents",
"fieldtype": "Int",
"label": "CPNI max per violation (cents)",
"description": "$251,322 per violation per DA 25-5 inflation adjustment (Jan 2025)"
},
{
"default": "251321500",
"fieldname": "cpni_total_max_cents",
"fieldtype": "Int",
"label": "CPNI total max (cents)",
"description": "$2,513,215 continuing-violation cap"
}
],
"issingle": 1,
"links": [],
"modified": "2026-04-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "Compliance",
"name": "Compliance Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 0,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 0,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -0,0 +1,15 @@
# Copyright (c) 2026, Performance West Inc. and contributors
# For license information, please see license.txt
from frappe.model.document import Document
class ComplianceSettings(Document):
"""Single DocType holding global compliance / filing flags.
Read by scripts.workers.services.telecom.auto_filing to decide whether
FCC/USAC filing handlers may submit to the portals or should stage for
admin review.
"""
pass

View file

@ -0,0 +1,242 @@
[
{
"name": "Sales Order-custom_identity_status",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_identity_status",
"fieldtype": "Select",
"options": "Pending\nVerified\nFailed\nNeeds Review",
"label": "Identity Verification Status",
"insert_after": "amended_from",
"default": "Pending",
"in_list_view": 0
},
{
"name": "Sales Order-custom_identity_session_id",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_identity_session_id",
"fieldtype": "Data",
"label": "Stripe Identity Session ID",
"insert_after": "custom_identity_status"
},
{
"name": "Sales Order-custom_order_type",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_order_type",
"fieldtype": "Select",
"options": "formation\ncanada_crtc\nbundle\ncompliance",
"label": "PW Order Type",
"insert_after": "custom_identity_session_id"
},
{
"name": "Sales Order-custom_external_order_id",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_external_order_id",
"fieldtype": "Data",
"label": "External Order ID (PG)",
"insert_after": "custom_order_type"
},
{
"name": "Sales Order-custom_payment_gateway",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_payment_gateway",
"fieldtype": "Data",
"label": "Payment Gateway Used",
"insert_after": "custom_external_order_id",
"read_only": 1
},
{
"name": "Sales Order-custom_surcharge_pct",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_surcharge_pct",
"fieldtype": "Float",
"label": "Payment Surcharge %",
"insert_after": "custom_payment_gateway",
"read_only": 1
},
{
"name": "Sales Order-custom_mailbox_address",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_mailbox_address",
"fieldtype": "Small Text",
"label": "Registered Office / Mailbox Address",
"insert_after": "custom_surcharge_pct"
},
{
"name": "Sales Order-custom_has_own_ca_address",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_has_own_ca_address",
"fieldtype": "Check",
"label": "Client Has Own BC Address",
"insert_after": "custom_mailbox_address",
"default": "0"
},
{
"name": "Sales Order-custom_own_ca_company",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_own_ca_company",
"fieldtype": "Data",
"label": "Binder Mailing Company / Operator",
"description": "For AMB orders: mailbox operator name. For own-address orders: company at that address.",
"insert_after": "custom_has_own_ca_address"
},
{
"name": "Sales Order-custom_own_ca_attn",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_own_ca_attn",
"fieldtype": "Data",
"label": "Binder Mailing Attn / Contact",
"insert_after": "custom_own_ca_company"
},
{
"name": "Sales Order-custom_esign_signed_at",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_esign_signed_at",
"fieldtype": "Datetime",
"label": "eSign Signed At",
"insert_after": "custom_own_ca_attn",
"read_only": 1
},
{
"name": "Sales Order-custom_esign_signer_email",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_esign_signer_email",
"fieldtype": "Data",
"label": "eSign Signer Email",
"insert_after": "custom_esign_signed_at",
"read_only": 1
},
{
"name": "Sales Order-custom_crtc_letter_minio_key",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_crtc_letter_minio_key",
"fieldtype": "Data",
"label": "CRTC Letter MinIO Key",
"insert_after": "custom_esign_signer_email",
"read_only": 1
},
{
"name": "Sales Order-custom_regulatory_email",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_regulatory_email",
"fieldtype": "Data",
"label": "Regulatory Email (regulatory@domain.ca)",
"insert_after": "custom_crtc_letter_minio_key"
},
{
"name": "Sales Order-custom_incorporation_number",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_incorporation_number",
"fieldtype": "Data",
"label": "Incorporation Number",
"insert_after": "custom_regulatory_email",
"read_only": 1
},
{
"name": "Sales Order-custom_incorporation_province",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_incorporation_province",
"fieldtype": "Select",
"label": "Incorporation Province",
"options": "BC\nON",
"default": "BC",
"insert_after": "custom_incorporation_number"
},
{
"name": "Sales Invoice-custom_surcharge_pct",
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fieldname": "custom_surcharge_pct",
"fieldtype": "Float",
"label": "Payment Surcharge %",
"insert_after": "amended_from",
"read_only": 1
},
{
"name": "Sales Invoice-custom_payment_gateway",
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fieldname": "custom_payment_gateway",
"fieldtype": "Data",
"label": "Payment Gateway",
"insert_after": "custom_surcharge_pct",
"read_only": 1
},
{
"name": "Sales Invoice-custom_external_order_id",
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fieldname": "custom_external_order_id",
"fieldtype": "Data",
"label": "External Order ID (PG)",
"insert_after": "custom_payment_gateway"
},
{
"name": "Sales Invoice-custom_stripe_payment_intent",
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fieldname": "custom_stripe_payment_intent",
"fieldtype": "Data",
"label": "Stripe Payment Intent ID",
"insert_after": "custom_external_order_id",
"read_only": 1
},
{
"name": "Payment Request-custom_stripe_session_id",
"doctype": "Custom Field",
"dt": "Payment Request",
"fieldname": "custom_stripe_session_id",
"fieldtype": "Data",
"label": "Stripe Session ID",
"insert_after": "amended_from",
"read_only": 1
},
{
"name": "Payment Request-custom_adyen_session_id",
"doctype": "Custom Field",
"dt": "Payment Request",
"fieldname": "custom_adyen_session_id",
"fieldtype": "Data",
"label": "Adyen Session ID",
"insert_after": "custom_stripe_session_id",
"read_only": 1
},
{
"name": "Sales Order-custom_generated_files",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_generated_files",
"fieldtype": "Small Text",
"label": "Generated Files (MinIO Paths)",
"insert_after": "custom_crtc_letter_minio_key",
"read_only": 1,
"hidden": 0,
"description": "Newline-separated MinIO paths for generated compliance documents"
},
{
"name": "Sales Order-custom_auto_filing_override",
"doctype": "Custom Field",
"dt": "Sales Order",
"fieldname": "custom_auto_filing_override",
"fieldtype": "Check",
"label": "Auto-Filing Admin Override",
"insert_after": "custom_generated_files",
"default": 0,
"description": "Per-order override: set to 1 after admin review to allow the FCC/USAC filing handler to submit this one order even when the global auto-filing toggle is off. Re-dispatched handler clears it."
}
]

View file

@ -0,0 +1,20 @@
[
{
"name": "Performance West Outgoing",
"doctype": "Email Account",
"email_id": "noreply@performancewest.net",
"email_account_name": "Performance West Outgoing",
"smtp_server": "co.carrierone.com",
"smtp_port": 587,
"use_tls": 1,
"login_id": "noreply@performancewest.net",
"enable_outgoing": 1,
"enable_incoming": 0,
"default_outgoing": 1,
"awaiting_password": 1,
"used_for_outgoing_emails_from": "System Notifications",
"add_signature": 0,
"auto_follow_threads": 0,
"enable_auto_reply": 0
}
]

View file

@ -0,0 +1,62 @@
[
{
"doctype": "Item",
"item_code": "CRTC-MAINT-ANNUAL",
"item_name": "CRTC Annual Maintenance",
"description": "Ongoing CRTC registration maintenance (Part II filings, regulatory contact, BITS updates).",
"item_group": "Services",
"stock_uom": "Nos",
"is_stock_item": 0,
"include_item_in_manufacturing": 0,
"standard_rate": 349.0,
"currency": "USD"
},
{
"doctype": "Item",
"item_code": "MAILBOX-RENEWAL",
"item_name": "Anytime Mailbox Renewal",
"description": "Annual Anytime Mailbox renewal for CRTC Canadian business address.",
"item_group": "Services",
"stock_uom": "Nos",
"is_stock_item": 0,
"include_item_in_manufacturing": 0,
"standard_rate": 180.0,
"currency": "USD"
},
{
"doctype": "Item",
"item_code": "BC-ANNUAL-REPORT",
"item_name": "BC Annual Report Filing",
"description": "BC Registries annual report filing for a BC-incorporated company.",
"item_group": "Services",
"stock_uom": "Nos",
"is_stock_item": 0,
"include_item_in_manufacturing": 0,
"standard_rate": 99.0,
"currency": "USD"
},
{
"doctype": "Item",
"item_code": "DOMAIN-RENEWAL-CA",
"item_name": ".ca Domain Renewal",
"description": "Annual .ca domain renewal for CRTC regulatory email address.",
"item_group": "Services",
"stock_uom": "Nos",
"is_stock_item": 0,
"include_item_in_manufacturing": 0,
"standard_rate": 25.0,
"currency": "USD"
},
{
"doctype": "Item",
"item_code": "COMPLIANCE-OTHER",
"item_name": "Compliance — Other",
"description": "Catch-all line item for compliance calendar entries without a dedicated item.",
"item_group": "Services",
"stock_uom": "Nos",
"is_stock_item": 0,
"include_item_in_manufacturing": 0,
"standard_rate": 0.0,
"currency": "USD"
}
]

View file

@ -0,0 +1,111 @@
[
{
"name": "CRTC Order Received",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "We received your CRTC carrier package order — {{ doc.name }}",
"message": "<p>Hi {{ doc.customer }},</p>\n<p>Thank you — we have received your Canada CRTC Carrier Package order <strong>{{ doc.custom_external_order_id or doc.name }}</strong>.</p>\n<p>Our team will begin processing your BC incorporation filing. You will receive updates at each stage.</p>\n<p>Estimated timeline: <strong>1015 business days</strong></p>\n<p>— Performance West Inc.<br><a href=\"https://performancewest.net\">performancewest.net</a></p>",
"event": "Value Change",
"value_changed": "workflow_state",
"condition": "doc.workflow_state == 'Received' and doc.custom_order_type == 'canada_crtc'",
"channel": "Email",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_document_field": "contact_email" }],
"enabled": 1
},
{
"name": "CRTC Incorporation Filed",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "Your BC corporation has been filed — {{ doc.name }}",
"message": "<p>Hi {{ doc.customer }},</p>\n<p>Great news — your British Columbia corporation has been filed with BC Registries.</p>\n<p>We are now proceeding with your CRTC and BITS registration. We will email you again when your CRTC letter is ready for your eSignature.</p>\n<p>— Performance West Inc.</p>",
"event": "Value Change",
"value_changed": "workflow_state",
"condition": "doc.workflow_state == 'Incorporation Filed' and doc.custom_order_type == 'canada_crtc'",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_document_field": "contact_email" }],
"enabled": 1
},
{
"name": "CRTC Letter Ready for eSign",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "ACTION REQUIRED — Sign your CRTC registration letter",
"message": "<p>Hi {{ doc.customer }},</p>\n<p>Your CRTC Notification Letter is ready for your eSignature. Please log in to your client portal to review and sign:</p>\n<p><a href=\"https://portal.performancewest.net\">Log in to Client Portal →</a></p>\n<p>This letter authorises your corporation to operate as a Canadian telecommunications carrier under the CRTC framework. It must be signed before we can submit your registration.</p>\n<p>— Performance West Inc.</p>",
"event": "Value Change",
"value_changed": "workflow_state",
"condition": "doc.workflow_state == 'Pending eSign' and doc.custom_order_type == 'canada_crtc'",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_document_field": "contact_email" }],
"enabled": 1
},
{
"name": "CRTC Registration Submitted",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "Your CRTC registration has been submitted",
"message": "<p>Hi {{ doc.customer }},</p>\n<p>We have submitted your CRTC carrier registration. Confirmation from the CRTC typically takes <strong>24 weeks</strong>.</p>\n<p>We will email you as soon as we receive confirmation. In the meantime, your DID (Canadian phone number) and .ca domain are being provisioned.</p>\n<p>— Performance West Inc.</p>",
"event": "Value Change",
"value_changed": "workflow_state",
"condition": "doc.workflow_state == 'CRTC Submitted' and doc.custom_order_type == 'canada_crtc'",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_document_field": "contact_email" }],
"enabled": 1
},
{
"name": "CRTC Order Delivered",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "Your corporate binder has been delivered — {{ doc.name }}",
"message": "<p>Hi {{ doc.customer }},</p>\n<p>Your Canada CRTC Carrier Package is complete. Your corporate binder (digital + physical) has been dispatched to your registered office address.</p>\n<p>Log in to your client portal to view your order details, download documents, and manage your services:</p>\n<p><a href=\"https://portal.performancewest.net\">Client Portal →</a></p>\n<p>Your annual maintenance ($349 USD/yr) will be invoiced on the anniversary of your order to keep your CRTC registration current.</p>\n<p>Thank you for choosing Performance West.</p>\n<p>— Performance West Inc.</p>",
"event": "Value Change",
"value_changed": "workflow_state",
"condition": "doc.workflow_state == 'Delivered' and doc.custom_order_type == 'canada_crtc'",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_document_field": "contact_email" }],
"enabled": 1
},
{
"name": "CRTC Client Selection Ready",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "ACTION REQUIRED — Choose your mailbox unit and phone number",
"message": "<p>Hi {{ doc.customer }},</p>\n<p>Your payment has been received and funds are available. Please complete the next step: choose your mailbox unit and Canadian phone number (DID) in the client portal.</p>\n<p><a href=\"https://performancewest.net/portal/setup?order={{ doc.custom_external_order_id }}\">Complete Setup →</a></p>\n<p>This step is required before we can finalise your BC registered office and begin provisioning your DID.</p>\n<p>— Performance West Inc.</p>",
"event": "Value Change",
"value_changed": "workflow_state",
"condition": "doc.workflow_state == 'Client Selection' and doc.custom_order_type == 'canada_crtc'",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_document_field": "contact_email" }],
"enabled": 1
},
{
"name": "Admin — New Sales Order",
"doctype": "Notification",
"document_type": "Sales Order",
"subject": "New Sales Order: {{ doc.name }} — {{ doc.customer }} ({{ doc.grand_total | round(2) }})",
"message": "<p>A new Sales Order has been submitted.</p>\n<table>\n<tr><td><strong>Order</strong></td><td>{{ doc.name }}</td></tr>\n<tr><td><strong>Customer</strong></td><td>{{ doc.customer }}</td></tr>\n<tr><td><strong>Total</strong></td><td>${{ doc.grand_total | round(2) }}</td></tr>\n<tr><td><strong>Order Type</strong></td><td>{{ doc.custom_order_type or 'N/A' }}</td></tr>\n<tr><td><strong>Contact</strong></td><td>{{ doc.contact_email or 'N/A' }}</td></tr>\n</table>\n<p><a href=\"{{ frappe.utils.get_url() }}/app/sales-order/{{ doc.name }}\">View in ERPNext →</a></p>",
"event": "Submit",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_role": "", "cc": "", "bcc": "", "condition": "", "receiver_by_document_field": "", "email_by_document_field": "ops@performancewest.net" }],
"enabled": 1
},
{
"name": "Admin — Payment Received",
"doctype": "Notification",
"document_type": "Payment Entry",
"subject": "Payment received: {{ doc.paid_amount | round(2) }} {{ doc.paid_to_account_currency or 'USD' }} — {{ doc.party_name or doc.party }}",
"message": "<p>A payment has been received.</p>\n<table>\n<tr><td><strong>Payment</strong></td><td>{{ doc.name }}</td></tr>\n<tr><td><strong>Customer</strong></td><td>{{ doc.party_name or doc.party }}</td></tr>\n<tr><td><strong>Amount</strong></td><td>${{ doc.paid_amount | round(2) }} {{ doc.paid_to_account_currency or 'USD' }}</td></tr>\n<tr><td><strong>Payment Type</strong></td><td>{{ doc.mode_of_payment or 'N/A' }}</td></tr>\n<tr><td><strong>Reference</strong></td><td>{{ doc.reference_no or 'N/A' }}</td></tr>\n</table>\n<p><a href=\"{{ frappe.utils.get_url() }}/app/payment-entry/{{ doc.name }}\">View in ERPNext →</a></p>",
"event": "Submit",
"channel": "Email",
"send_to_all_assignees": 0,
"recipients": [{ "receiver_by_role": "", "cc": "", "bcc": "", "condition": "", "receiver_by_document_field": "", "email_by_document_field": "ops@performancewest.net" }],
"enabled": 1
}
]

View file

@ -0,0 +1,101 @@
[
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 99.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "RA-RENEWAL",
"name": "RA Renewal (Annual — $99)",
"plan_name": "RA Renewal (Annual — $99)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 49.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "RA-RENEWAL-WY",
"name": "RA Renewal Wyoming (Annual — $49)",
"plan_name": "RA Renewal Wyoming (Annual — $49)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 99.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "ANNUAL-REPORT",
"name": "Annual Report Filing (Annual — $99)",
"plan_name": "Annual Report Filing (Annual — $99)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 349.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "CRTC-MAINT-ANNUAL",
"name": "CRTC Annual Maintenance ($349)",
"plan_name": "CRTC Annual Maintenance ($349)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 179.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "US-FORMATION-MAINT",
"name": "US Formation Maintenance Bundle ($179)",
"plan_name": "US Formation Maintenance Bundle ($179)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 179.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "CA-FORMATION-MAINT",
"name": "CA Formation Maintenance Bundle ($179)",
"plan_name": "CA Formation Maintenance Bundle ($179)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 99.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "CDR-STORAGE-TIER1",
"name": "CDR Storage Tier 1 (50 GB / 50M calls — $99/yr)",
"plan_name": "CDR Storage Tier 1 (50 GB / 50M calls — $99/yr)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 299.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "CDR-STORAGE-TIER2",
"name": "CDR Storage Tier 2 (250 GB / 250M calls — $299/yr)",
"plan_name": "CDR Storage Tier 2 (250 GB / 250M calls — $299/yr)",
"price_determination": "Fixed Rate"
},
{
"billing_interval": "Year",
"billing_interval_count": 1,
"cost": 799.0,
"currency": "USD",
"doctype": "Subscription Plan",
"item": "CDR-STORAGE-TIER3",
"name": "CDR Storage Tier 3 (1 TB / 1B calls — $799/yr)",
"plan_name": "CDR Storage Tier 3 (1 TB / 1B calls — $799/yr)",
"price_determination": "Fixed Rate"
}
]

View file

@ -0,0 +1,17 @@
frappe.ui.form.on("PW Stripe Settings", {
refresh: function(frm) {
frm.add_custom_button(__("Test Connection"), function() {
frappe.call({
method: "performancewest_erpnext.payment_gateways.doctype.pw_stripe_settings.pw_stripe_settings.validate_stripe_credentials",
args: { gateway_name: frm.doc.gateway_name },
callback: function(r) {
if (r.message && r.message.valid) {
frappe.msgprint(__("Stripe credentials are valid."));
} else {
frappe.msgprint(__("Invalid Stripe credentials: ") + (r.message?.error || "Unknown error"));
}
}
});
});
}
});

View file

@ -0,0 +1,86 @@
{
"actions": [],
"autoname": "field:gateway_name",
"creation": "2026-03-28 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"gateway_name",
"enabled",
"column_break_1",
"publishable_key",
"secret_key",
"webhook_secret",
"payment_method_types"
],
"fields": [
{
"fieldname": "gateway_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Gateway Name",
"reqd": 1,
"unique": 1
},
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "publishable_key",
"fieldtype": "Data",
"label": "Publishable Key",
"reqd": 1
},
{
"fieldname": "secret_key",
"fieldtype": "Password",
"label": "Secret Key",
"reqd": 1
},
{
"fieldname": "webhook_secret",
"fieldtype": "Password",
"label": "Webhook Secret"
},
{
"fieldname": "payment_method_types",
"fieldtype": "Small Text",
"label": "Payment Method Types",
"description": "Comma-separated list of Stripe payment method types, e.g. card,klarna or us_bank_account",
"reqd": 1
}
],
"links": [],
"modified": "2026-03-28 00:00:00.000000",
"modified_by": "Administrator",
"module": "Payment Gateways",
"name": "PW Stripe Settings",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -0,0 +1,196 @@
"""
PW Stripe Settings Custom Stripe Checkout Sessions gateway for ERPNext.
Uses Stripe Checkout Sessions (redirect-based) instead of Stripe.js direct card collection.
Supports: card, klarna (Card instance), us_bank_account (ACH instance).
Two gateway instances are expected:
- "Card" payment_method_types: "card,klarna" 3% surcharge
- "ACH" payment_method_types: "us_bank_account" 0% surcharge
"""
import json
import stripe
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_url, call_hook_method, nowdate
from frappe.integrations.utils import create_request_log
from urllib.parse import urlencode
from payments.utils import create_payment_gateway
class PWStripeSettings(Document):
def on_update(self):
create_payment_gateway(
"PW-Stripe-" + self.gateway_name,
settings="PW Stripe Settings",
controller=self.gateway_name,
)
call_hook_method("payment_gateway_enabled", gateway="PW-Stripe-" + self.gateway_name)
if not self.flags.ignore_mandatory:
self.validate_stripe_credentials()
def validate_stripe_credentials(self):
"""Test Stripe credentials by making a minimal API call."""
try:
client = self._get_stripe_client()
client.balance.retrieve()
except stripe.AuthenticationError:
frappe.throw(_("Invalid Stripe Secret Key. Please check your credentials."))
except stripe.PermissionError as e:
frappe.throw(_(f"Stripe permission error: {e}"))
except Exception as e:
frappe.throw(_(f"Could not connect to Stripe: {e}"))
def validate_transaction_currency(self, currency):
supported = ["USD", "CAD", "EUR", "GBP", "AUD"]
if currency.upper() not in supported:
frappe.throw(_(f"Currency {currency} is not supported. Supported: {', '.join(supported)}"))
def validate_minimum_transaction_amount(self, currency, amount):
minimums = {"USD": 0.50, "CAD": 0.50, "EUR": 0.50, "GBP": 0.30, "AUD": 0.50}
minimum = minimums.get(currency.upper(), 0.50)
if float(amount) < minimum:
frappe.throw(_(f"Minimum transaction amount for {currency} is {minimum}"))
def get_payment_url(self, **kwargs):
"""
Called by ERPNext Payment Request to get the checkout redirect URL.
Returns URL to our pw_stripe_checkout page with all params encoded.
"""
return get_url(f"./pw_stripe_checkout?{urlencode(kwargs)}")
def create_checkout_session(
self,
amount_cents: int,
currency: str,
payment_request_name: str,
reference_doctype: str,
reference_name: str,
customer_email: str,
success_url: str,
cancel_url: str,
description: str = "",
) -> stripe.checkout.Session:
"""
Create a Stripe Checkout Session.
payment_method_types is parsed from self.payment_method_types field
(comma-separated: "card,klarna" or "us_bank_account").
"""
client = self._get_stripe_client()
methods = [m.strip() for m in (self.payment_method_types or "card").split(",") if m.strip()]
session_params: dict = {
"mode": "payment",
"payment_method_types": methods,
"line_items": [
{
"price_data": {
"currency": currency.lower(),
"product_data": {
"name": description or f"Payment for {reference_name}",
"description": f"Order: {reference_name}",
},
"unit_amount": amount_cents,
},
"quantity": 1,
}
],
"success_url": success_url,
"cancel_url": cancel_url,
"customer_email": customer_email or None,
"metadata": {
"payment_request": payment_request_name,
"reference_doctype": reference_doctype,
"reference_name": reference_name,
"frappe_site": frappe.local.site,
},
}
# ACH-specific: require bank account verification
if "us_bank_account" in methods:
session_params["payment_method_options"] = {
"us_bank_account": {
"financial_connections": {"permissions": ["payment_method"]},
},
}
session = client.checkout.sessions.create(**session_params)
return session
def handle_webhook(self, payload: bytes, sig_header: str) -> "dict | None":
"""
Verify and parse a Stripe webhook event.
Returns normalized event dict or None for ignored events.
Raises ValueError on signature failure.
"""
client = self._get_stripe_client()
webhook_secret = self.get_password(fieldname="webhook_secret", raise_exception=False)
if not webhook_secret:
frappe.log_error(
"[PW Stripe] webhook_secret not set — skipping signature verification",
"Stripe Webhook Warning",
)
event = stripe.Event.construct_from(
json.loads(payload), client.api_key
)
else:
try:
event = client.webhooks.construct_event(payload, sig_header, webhook_secret)
except stripe.SignatureVerificationError as e:
raise ValueError(f"Stripe webhook signature verification failed: {e}")
if event.type == "checkout.session.completed":
session = event.data.object
return {
"event_type": "payment.succeeded",
"session_id": session.id,
"payment_intent": session.payment_intent,
"amount_cents": session.amount_total,
"currency": session.currency,
"customer_email": session.customer_email,
"metadata": dict(session.metadata or {}),
}
if event.type == "checkout.session.expired":
session = event.data.object
return {
"event_type": "payment.expired",
"session_id": session.id,
"metadata": dict(session.metadata or {}),
}
return None # Ignore other event types
def get_session_status(self, session_id: str) -> dict:
"""Retrieve Checkout Session status from Stripe."""
client = self._get_stripe_client()
session = client.checkout.sessions.retrieve(session_id)
status_map = {"complete": "paid", "open": "pending", "expired": "expired"}
return {
"status": status_map.get(session.status, "pending"),
"payment_intent": session.payment_intent,
"amount_cents": session.amount_total,
}
def _get_stripe_client(self) -> stripe.Stripe:
secret_key = self.get_password(fieldname="secret_key", raise_exception=False)
if not secret_key:
frappe.throw(_("Stripe Secret Key is not configured."))
return stripe.Stripe(secret_key)
@frappe.whitelist()
def validate_stripe_credentials(gateway_name: str) -> dict:
"""Whitelisted method called from the form's Test Connection button."""
try:
doc = frappe.get_doc("PW Stripe Settings", gateway_name)
doc.validate_stripe_credentials()
return {"valid": True}
except Exception as e:
return {"valid": False, "error": str(e)}

View file

@ -0,0 +1,40 @@
{% extends "templates/web.html" %}
{% block title %}Redirecting to Checkout…{% endblock %}
{% block page_content %}
<div class="container" style="text-align:center; padding: 4rem 1rem;">
{% if error %}
<div class="alert alert-danger" role="alert">
<h4>Payment Error</h4>
<p>{{ error }}</p>
<a href="/" class="btn btn-secondary">Return Home</a>
</div>
{% else %}
<div id="redirect-notice">
<h3>Redirecting to secure checkout…</h3>
<p>Please wait. You will be redirected to Stripe to complete your payment.</p>
<div class="spinner-border text-primary" role="status" aria-label="Loading">
<span class="visually-hidden">Loading…</span>
</div>
</div>
<script>
(function () {
var url = {{ checkout_url | tojson }};
if (url) {
window.location.href = url;
} else {
document.getElementById("redirect-notice").innerHTML =
'<div class="alert alert-danger">' +
'<p>Could not initialize payment. Please contact support.</p>' +
'<a href="/" class="btn btn-secondary">Return Home</a>' +
'</div>';
}
})();
</script>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,113 @@
"""
PW Stripe Checkout Page
URL: /pw_stripe_checkout?<payment_request_params>
Flow:
1. Receive kwargs from get_payment_url() includes payment_request name, amount, etc.
2. Look up the Payment Request in ERPNext
3. Look up the PW Stripe Settings gateway controller
4. Create a Stripe Checkout Session
5. Store session_id on the Payment Request (for status polling)
6. Redirect to session.url
"""
import frappe
from frappe import _
from frappe.utils import get_url
def get_context(context):
context.no_cache = 1
# ── Parse incoming query params ──────────────────────────────────────────
form_dict = frappe.form_dict
payment_request_name = form_dict.get("payment_request") or form_dict.get("reference_name")
reference_doctype = form_dict.get("reference_doctype", "Sales Invoice")
reference_name = form_dict.get("reference_name", "")
payer_email = form_dict.get("payer_email", "")
amount = form_dict.get("amount", "0")
currency = form_dict.get("currency", "USD")
return_url = form_dict.get("return_url", get_url("/"))
cancel_url_param = form_dict.get("cancel_url", get_url("/"))
try:
# ── Look up the Payment Request ───────────────────────────────────────
if not payment_request_name:
raise ValueError("payment_request parameter is required")
payment_request = frappe.get_doc("Payment Request", payment_request_name)
if payment_request.status in ("Paid", "Cancelled"):
context.error = _("This payment request has already been processed.")
context.checkout_url = ""
return
# ── Determine gateway settings ────────────────────────────────────────
# payment_gateway_account identifies the active card or ACH gateway account
# The controller name is stored in the Payment Gateway Account
gateway_account = frappe.get_doc("Payment Gateway Account", payment_request.payment_gateway_account)
settings_name = gateway_account.gateway_settings # e.g. "Card" or "ACH"
stripe_settings = frappe.get_doc("PW Stripe Settings", settings_name)
if not stripe_settings.enabled:
raise ValueError(f"PW Stripe Settings '{settings_name}' is disabled")
# ── Build success/cancel URLs ─────────────────────────────────────────
# Success URL must include {CHECKOUT_SESSION_ID} for Stripe to substitute
domain = frappe.local.conf.get("host_name") or frappe.utils.get_host_name()
site_url = f"https://{domain}" if not domain.startswith("http") else domain
# The website's order success page (Astro site on same domain or separate)
pw_domain = frappe.db.get_single_value("System Settings", "website_baseurl") or site_url
success_url = (
f"{pw_domain}/order/success"
f"?session_id={{CHECKOUT_SESSION_ID}}"
f"&order_id={frappe.utils.escape_html(reference_name)}"
f"&order_type={frappe.utils.escape_html(form_dict.get('order_type', ''))}"
)
cancel_url = (
f"{pw_domain}/order/cancel"
f"?order_id={frappe.utils.escape_html(reference_name)}"
)
# ── Convert amount to cents ───────────────────────────────────────────
amount_cents = int(float(amount) * 100)
# ── Create Stripe Checkout Session ────────────────────────────────────
description = f"Payment for {reference_doctype}: {reference_name}"
session = stripe_settings.create_checkout_session(
amount_cents=amount_cents,
currency=currency,
payment_request_name=payment_request_name,
reference_doctype=reference_doctype,
reference_name=reference_name,
customer_email=payer_email,
success_url=success_url,
cancel_url=cancel_url,
description=description,
)
# ── Store Stripe session ID on the Payment Request ────────────────────
frappe.db.set_value(
"Payment Request",
payment_request_name,
{
"custom_stripe_session_id": session.id,
"status": "Initiated",
},
)
frappe.db.commit()
# ── Redirect to Stripe Checkout ───────────────────────────────────────
context.checkout_url = session.url
context.error = None
except Exception as e:
frappe.log_error(
f"[pw_stripe_checkout] Error for payment_request={payment_request_name}: {e}",
"PW Stripe Checkout Error",
)
context.error = _("Could not initialize payment. Please contact support.")
context.checkout_url = ""

View file

@ -0,0 +1,46 @@
app_name = "performancewest_erpnext"
app_title = "Performance West ERPNext"
app_publisher = "Performance West Inc."
app_description = "Custom payment gateways, surcharge hooks, and identity verification for Performance West"
app_email = "support@performancewest.net"
app_license = "MIT"
# Fixtures to import on bench migrate
fixtures = [
{"dt": "Custom Field", "filters": [["dt", "in", ["Sales Order", "Sales Invoice", "Payment Request"]]]},
{"dt": "Notification", "filters": [["name", "like", "CRTC%"]]},
{"dt": "Notification", "filters": [["name", "like", "Admin %"]]},
{"dt": "Email Account", "filters": [["email_account_name", "=", "Performance West Outgoing"]]},
# Subscription plans for recurring renewals (RA, annual report, CRTC maintenance,
# formation maintenance bundles). Pricing updated per go-live-todo.md:37, 261.
{"dt": "Subscription Plan"},
# Service Items referenced by renewal_worker._compliance_type_to_item —
# CRTC-MAINT-ANNUAL, MAILBOX-RENEWAL, BC-ANNUAL-REPORT, DOMAIN-RENEWAL-CA,
# and the COMPLIANCE-OTHER catch-all.
{"dt": "Item", "filters": [["item_code", "in", [
"CRTC-MAINT-ANNUAL", "MAILBOX-RENEWAL", "BC-ANNUAL-REPORT",
"DOMAIN-RENEWAL-CA", "COMPLIANCE-OTHER",
]]]},
]
# Portal menu items — adds "My Orders" to the ERPNext portal sidebar
portal_menu_items = [
{"title": "My Orders", "route": "/orders", "reference_doctype": "Sales Order", "role": "Customer"},
]
# Document event hooks
doc_events = {
"Payment Request": {
"before_insert": "performancewest_erpnext.payments.surcharge.inject_surcharge",
},
"Sales Order": {
"before_submit": "performancewest_erpnext.payments.identity_gate.check_identity",
},
}
# pw_stripe_checkout is served from www/pw_stripe_checkout.py automatically
# Exempt Stripe webhook from CSRF — Stripe uses signature verification instead
csrf_ignore_methods = [
"performancewest_erpnext.api.stripe_webhook",
]

View file

@ -0,0 +1,2 @@
Payment Gateways
Compliance

View file

@ -0,0 +1,175 @@
"""
Identity Verification Gate for CRTC Sales Orders.
Runs on Sales Order.before_submit.
For orders containing the Canada CRTC Telecom Carrier Package item:
- BLOCKS submission if identity_status is Pending or Failed
- ALLOWS submission if identity_status is Verified or Needs Review
- LOGS an admin alert if status is Needs Review (requires manual admin review)
- Does nothing for non-CRTC orders
This prevents payment collection for clients whose identity has not been verified,
per FINTRAC and CRTC know-your-client requirements.
Race condition handling:
- If custom_identity_status is missing/null, treat as Pending (block)
- Uses frappe.db.get_value for the freshest data at submit time (not stale doc cache)
- Creates a Notification (ERPNext built-in) for needs_review cases so admin sees it
"""
import frappe
from frappe import _
# Item code that triggers identity verification requirement
CRTC_ITEM_CODE = "Canada CRTC Telecom Carrier Package"
# Valid statuses that allow payment to proceed
ALLOWED_STATUSES = {"Verified", "Needs Review"}
# Statuses that hard-block submission
BLOCKED_STATUSES = {"Failed"}
def check_identity(doc, method=None):
"""
doc_events hook: Sales Order.before_submit
Checks identity verification status for CRTC orders.
Throws frappe.ValidationError to block submission if not verified.
"""
# Only applies to CRTC orders
if not _has_crtc_item(doc):
return
# Get freshest identity status from DB (not stale from doc load)
identity_status = frappe.db.get_value(
"Sales Order",
doc.name,
"custom_identity_status",
) or "Pending"
session_id = frappe.db.get_value(
"Sales Order",
doc.name,
"custom_identity_session_id",
) or ""
if identity_status in ALLOWED_STATUSES:
if identity_status == "Needs Review":
# Allow to proceed but create admin notification
_notify_admin_needs_review(doc, session_id)
return # Proceed normally
if identity_status in BLOCKED_STATUSES:
frappe.throw(
_(
"Identity verification has failed for this order. "
"Payment cannot be collected. "
"Please contact the client to re-submit identity documents."
),
frappe.ValidationError,
title=_("Identity Verification Failed"),
)
# Pending or unknown status — block
frappe.throw(
_(
"Identity verification is required before this order can be submitted. "
"Current status: {0}. "
"The client must complete identity verification at the order link."
).format(identity_status),
frappe.ValidationError,
title=_("Identity Verification Required"),
)
def _has_crtc_item(doc) -> bool:
"""Return True if the Sales Order contains the CRTC package item."""
if not doc.items:
return False
for item in doc.items:
if item.item_code == CRTC_ITEM_CODE:
return True
return False
def _notify_admin_needs_review(doc, session_id: str):
"""
Create an ERPNext Notification / To Do for admin when identity needs manual review.
Uses frappe.log_error so it appears in Error Log for immediate visibility,
and creates a ToDo assigned to the Administrator role.
"""
message = (
f"CRTC Order {doc.name} (Customer: {doc.customer_name or doc.customer}) "
f"has identity status 'Needs Review'. "
f"Stripe Identity Session: {session_id or 'N/A'}. "
f"Manual admin review required before delivering service."
)
# Log to error log for visibility
frappe.log_error(message, "CRTC Identity Needs Review")
# Create a ToDo for Administrator
try:
todo = frappe.get_doc({
"doctype": "ToDo",
"status": "Open",
"priority": "High",
"description": message,
"reference_type": "Sales Order",
"reference_name": doc.name,
"assigned_by": "Administrator",
"owner": "Administrator",
"date": frappe.utils.add_days(frappe.utils.today(), 1), # Due tomorrow
})
todo.flags.ignore_permissions = True
todo.insert()
frappe.db.commit()
except Exception as e:
# Don't block order submission if ToDo creation fails
frappe.log_error(
f"Could not create admin ToDo for needs_review order {doc.name}: {e}",
"CRTC Identity Gate Warning",
)
def update_identity_status(order_name: str, status: str, session_id: str = ""):
"""
Update the identity verification status on a Sales Order.
Called by the Express API identity webhook handler after Stripe Identity result.
Exposed as a whitelisted method via api.py.
Args:
order_name: ERPNext Sales Order name (e.g. "SAL-ORD-2026-00001")
status: "Verified" | "Failed" | "Needs Review" | "Pending"
session_id: Stripe Identity Verification Session ID
"""
valid_statuses = {"Pending", "Verified", "Failed", "Needs Review"}
if status not in valid_statuses:
frappe.throw(_(f"Invalid identity status '{status}'. Must be one of: {', '.join(valid_statuses)}"))
# Verify order exists
if not frappe.db.exists("Sales Order", order_name):
frappe.throw(_(f"Sales Order '{order_name}' not found"))
update = {"custom_identity_status": status}
if session_id:
update["custom_identity_session_id"] = session_id
frappe.db.set_value("Sales Order", order_name, update)
frappe.db.commit()
frappe.logger().info(
f"[identity_gate] Updated order {order_name} identity status: {status} "
f"(session: {session_id})"
)
# If needs review, create admin notification immediately (not at submit time)
if status == "Needs Review":
try:
so = frappe.get_doc("Sales Order", order_name)
_notify_admin_needs_review(so, session_id)
except Exception:
pass # Don't fail if notification fails

View file

@ -0,0 +1,162 @@
"""
Payment surcharge injection hook.
Runs before_insert on Payment Request.
Adds a surcharge line item to the linked Sales Invoice based on the selected gateway.
Surcharge rates (flat per gateway, applies to service fees only not government fees):
Stripe-Card : 3.0% (Visa/MC/Amex + Apple Pay/Google Pay via Stripe Checkout)
Stripe-ACH : 0.0% (ACH Direct Debit via Stripe)
Stripe-Klarna : 6.0% (Klarna Pay in 4 via Stripe Stripe cost: 5.99%+$0.30)
Stripe-PayPal : 3.0% (PayPal Orders v2 direct integration)
Crypto : 0.0% (SHKeeper self-hosted BTC/ETH/USDC/USDT/MATIC/TRX/BNB/LTC/DOGE)
Adyen-* entries are retained for the future rollout (merchant application
pending docs/go-live-todo.md:84, 330).
"""
import frappe
from frappe import _
# Gateway name → surcharge percentage
# Keyed by exact Payment Gateway Account name (ERPNext).
#
# Current go-live uses the Stripe-* gateways; Adyen-* entries are kept for
# the future Adyen rollout. The Express API applies surcharges at
# Stripe-Checkout time and records the gateway label on the Sales Order —
# this dict is the source of truth for ERPNext-driven Payment Requests
# (currently the crypto flow and any manually-created renewal invoices).
GATEWAY_SURCHARGES: dict[str, float] = {
# ── Stripe (live) ──
"Stripe-Card": 3.0,
"Stripe-ACH": 0.0,
"Stripe-Klarna": 6.0,
"Stripe-PayPal": 3.0,
# ── Crypto (live — SHKeeper) ──
"Crypto": 0.0,
"Crypto-Crypto": 0.0, # payment gateway account convention in ERPNext
# ── Adyen (pending merchant approval — future) ──
"Adyen-Card": 3.0,
"Adyen-ACH": 0.0,
"Adyen-Klarna": 5.0,
"Adyen-CashApp": 3.0,
"Adyen-AmazonPay": 3.0,
}
# Item group that contains government/filing fees — excluded from surcharge
GOVERNMENT_FEE_ITEM_GROUP = "Government Fees"
# Item code used for the surcharge line item
SURCHARGE_ITEM_CODE = "Payment Processing Fee"
def inject_surcharge(doc, method=None):
"""
doc_events hook: Payment Request.before_insert
Calculates and injects a surcharge line item into the linked Sales Invoice
before the Payment Request is created.
Does nothing if:
- Gateway has 0% surcharge
- Reference document is not a Sales Invoice
- Invoice is already submitted (docstatus=1)
"""
if doc.reference_doctype != "Sales Invoice":
return
gateway = doc.payment_gateway_account
if not gateway:
return
# Determine surcharge rate
pct = _get_surcharge_pct(gateway)
if pct <= 0:
return
try:
invoice = frappe.get_doc("Sales Invoice", doc.reference_name)
except frappe.DoesNotExistError:
frappe.log_error(
f"[surcharge] Sales Invoice {doc.reference_name} not found",
"Surcharge Injection Error",
)
return
# Invoice must be draft (docstatus=0) for us to modify it
if invoice.docstatus != 0:
frappe.log_error(
f"[surcharge] Invoice {doc.reference_name} is already submitted (docstatus={invoice.docstatus}), cannot add surcharge",
"Surcharge Injection Error",
)
return
# Check if surcharge already added (idempotency)
for item in invoice.items:
if item.item_code == SURCHARGE_ITEM_CODE:
return # Already present, skip
# Calculate surcharge on service items only (exclude government fee item group)
service_total = _get_service_total(invoice)
if service_total <= 0:
return
surcharge_amount = round(service_total * pct / 100, 2)
if surcharge_amount < 0.01:
return
# Add surcharge line item to invoice
invoice.append("items", {
"item_code": SURCHARGE_ITEM_CODE,
"item_name": f"Payment Processing Fee ({pct}%)",
"qty": 1,
"rate": surcharge_amount,
"amount": surcharge_amount,
"description": f"{gateway} processing surcharge — {pct}% of service fees",
"uom": "Nos",
})
# Recalculate invoice totals
invoice.flags.ignore_permissions = True
invoice.set_missing_values()
invoice.calculate_taxes_and_totals()
invoice.save()
# Update the Payment Request amount to match the new invoice total
doc.grand_total = invoice.grand_total
doc.grand_total_in_base_currency = invoice.grand_total
frappe.logger().info(
f"[surcharge] Added {pct}% surcharge (${surcharge_amount:.2f}) to invoice {doc.reference_name} for gateway {gateway}"
)
def _get_surcharge_pct(gateway: str) -> float:
"""
Return the surcharge percentage for a gateway.
Matches by exact name, then by prefix for gateway instances with suffixes.
"""
if gateway in GATEWAY_SURCHARGES:
return GATEWAY_SURCHARGES[gateway]
# Prefix match: e.g. "Adyen-Card-USD" → matches "Adyen-Card"
for prefix, pct in GATEWAY_SURCHARGES.items():
if gateway.startswith(prefix):
return pct
# Default: no surcharge for unknown gateways
return 0.0
def _get_service_total(invoice) -> float:
"""
Sum item amounts excluding items in the Government Fees item group.
Also excludes any existing Payment Processing Fee items (idempotency).
"""
total = 0.0
for item in invoice.items:
if item.item_code == SURCHARGE_ITEM_CODE:
continue
item_group = frappe.db.get_value("Item", item.item_code, "item_group") or ""
if item_group == GOVERNMENT_FEE_ITEM_GROUP:
continue
total += float(item.amount or 0)
return total

View file

@ -0,0 +1,221 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
.pw-admin-wrap { max-width: 1100px; margin: 0 auto; }
.pw-admin-wrap h1 { color: #1a2744; margin: 0 0 1rem; }
.pw-admin-filter { margin-bottom: 1rem; padding: 0.5rem 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; display: flex; gap: 0.5rem; align-items: center; }
.pw-admin-filter input { flex: 1 1 200px; padding: 0.4rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 5px; font-size: 0.9rem; }
.pw-admin-tbl { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.pw-admin-tbl th, .pw-admin-tbl td { padding: 0.55rem 0.75rem; text-align: left; border-bottom: 1px solid #f0f4f8; vertical-align: top; }
.pw-admin-tbl th { background: #f8fafc; color: #475569; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
.pw-admin-tbl code { font-size: 0.82rem; color: #334155; }
.pw-admin-slug { display: inline-block; padding: 0.1rem 0.5rem; background: #e0e7ff; color: #1e3a8a; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
.pw-pkt-files { margin: 0.3rem 0 0; padding: 0; list-style: none; font-size: 0.82rem; }
.pw-pkt-files li { margin: 0.15rem 0; }
.pw-pkt-files a { color: #2d4e78; }
.pw-fidelity-badge {
display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.45rem;
border-radius: 10px; font-size: 0.7rem; font-weight: 600;
}
.pw-fidelity-ok { background: #d1fae5; color: #065f46; }
.pw-fidelity-warn { background: #fef3c7; color: #92400e; }
.pw-fidelity-info { background: #e0f2fe; color: #0369a1; }
.pw-btn-approve { padding: 0.4rem 0.9rem; background: #059669; color: #fff; border: 0; border-radius: 5px; font-weight: 600; cursor: pointer; font-size: 0.85rem; }
.pw-btn-approve:disabled { opacity: 0.6; cursor: not-allowed; }
.pw-msg { margin-top: 0.4rem; font-size: 0.82rem; }
.pw-msg-ok { color: #065f46; }
.pw-msg-err { color: #b91c1c; }
</style>
<div class="pw-admin-wrap">
<h1>Admin Filings Review</h1>
<p style="color:#64748b; margin-bottom: 1rem;">
Paid compliance orders whose filing is staged for human approval. Review
the packet files, then click <em>Approve &amp; File</em> to submit to the
FCC/USAC portal. The auto-filing override is one-shot per order.
</p>
<div class="pw-admin-filter">
<input type="text" id="pw-admin-search" placeholder="Filter by order #, carrier, slug, email…">
</div>
<table class="pw-admin-tbl">
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Carrier</th>
<th>Service</th>
<th>Paid</th>
<th>Packet</th>
<th></th>
</tr>
</thead>
<tbody id="pw-admin-rows">
{% if not pending_orders %}
<tr><td colspan="7" style="text-align:center; padding: 2rem; color:#64748b;">
No filings awaiting admin review.
</td></tr>
{% endif %}
{% for row in pending_orders %}
<tr data-search="{{ row.order_number|lower }} {{ (row.entity_name or '')|lower }} {{ row.service_slug|lower }} {{ (row.customer_email or '')|lower }}">
<td>
<code>{{ row.order_number }}</code><br>
<span style="color:#64748b; font-size:.78rem;">{{ row.state }}</span>
</td>
<td>
{{ row.customer_name or '—' }}<br>
<span style="color:#64748b; font-size:.78rem;">{{ row.customer_email }}</span>
</td>
<td>
{{ row.entity_name or '—' }}<br>
{% if row.entity_frn %}<span style="color:#64748b; font-size:.78rem;">FRN {{ row.entity_frn }}</span>{% endif %}
</td>
<td>
<span class="pw-admin-slug">{{ row.service_slug }}</span><br>
<span style="font-size:.8rem;">{{ row.service_name }}</span>
{% if row.line_105_primary %}
<div style="font-size:.72rem; color:#475569; margin-top:.2rem;">
L105: <strong>{{ row.line_105_primary }}</strong>
{% if row.line_105_categories %}+{{ (row.line_105_categories | length) - 1 }}{% endif %}
</div>
{% endif %}
{% if row.deminimis_result_is_exempt is not none %}
<div class="pw-fidelity-badge pw-fidelity-{% if row.deminimis_result_is_exempt %}ok{% else %}warn{% endif %}">
{% if row.deminimis_result_is_exempt %}✓ De minimis{% else %}Not de minimis (${{ "{:,.2f}".format((row.deminimis_estimated_contrib_cents or 0) / 100.0) }}){% endif %}
</div>
{% endif %}
{% if row.active_reseller_cert_count and row.active_reseller_cert_count > 0 %}
<div class="pw-fidelity-badge pw-fidelity-{% if row.expiring_soon_cert_count and row.expiring_soon_cert_count > 0 %}warn{% else %}ok{% endif %}">
{{ row.active_reseller_cert_count }} reseller cert{{ 's' if row.active_reseller_cert_count != 1 else '' }}
{% if row.expiring_soon_cert_count and row.expiring_soon_cert_count > 0 %}
({{ row.expiring_soon_cert_count }} expiring &lt;90d)
{% endif %}
</div>
{% endif %}
{% if row.traffic_study_compliance_ok is not none %}
<div class="pw-fidelity-badge pw-fidelity-{% if row.traffic_study_compliance_ok %}ok{% else %}warn{% endif %}">
Traffic study: {% if row.traffic_study_compliance_ok %}✓ FCC-compliant{% else %}⚠ not stamped{% endif %}
{% if row.traffic_study_usac_submitted_at %}, submitted{% endif %}
</div>
{% endif %}
{% if row.icc_revenue_ytd_cents and row.icc_revenue_ytd_cents > 0 %}
<div class="pw-fidelity-badge pw-fidelity-info">
ICC ${{ "{:,.2f}".format(row.icc_revenue_ytd_cents / 100.0) }}
</div>
{% endif %}
{% if row.intake_data_validated is not none and not row.intake_data_validated %}
<div class="pw-fidelity-badge pw-fidelity-warn">⚠ intake not validated</div>
{% endif %}
</td>
<td>
{{ frappe.utils.format_date(row.paid_at or row.created_at) }}
</td>
<td>
{% if row.generated_files %}
<ul class="pw-pkt-files">
{% for f in row.generated_files %}
<li><a href="#" class="pw-pkt-link" data-key="{{ f }}">{{ f.split('/') | last }}</a></li>
{% endfor %}
</ul>
{% else %}
<em style="color:#64748b;">(no packet yet — handler may still be running)</em>
{% endif %}
</td>
<td>
<button class="pw-btn-approve"
data-order="{{ row.order_number }}"
{% if not row.generated_files %}disabled{% endif %}>
Approve &amp; File
</button>
<div class="pw-msg" data-msg-for="{{ row.order_number }}"></div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function () {
const adminToken = "{{ admin_token or '' }}";
// Filter
const search = document.getElementById("pw-admin-search");
search?.addEventListener("input", () => {
const q = search.value.trim().toLowerCase();
document.querySelectorAll("#pw-admin-rows tr[data-search]").forEach((tr) => {
tr.style.display = (!q || tr.getAttribute("data-search").includes(q)) ? "" : "none";
});
});
// Packet file links — Frappe's presign_minio returns JSON, so we fetch
// the signed URL and navigate to it ourselves.
document.querySelectorAll(".pw-pkt-link").forEach((a) => {
a.addEventListener("click", async (ev) => {
ev.preventDefault();
const key = a.getAttribute("data-key");
try {
const resp = await fetch(
`/api/method/performancewest_erpnext.api.presign_minio?key=${encodeURIComponent(key)}`,
{ credentials: "same-origin" },
);
const data = await resp.json();
const url = data?.message?.url;
if (url) {
window.open(url, "_blank", "noopener");
} else {
alert("Could not generate download URL: " + (data?.exc || JSON.stringify(data)));
}
} catch (err) {
alert("Network error fetching download URL: " + err.message);
}
});
});
// Approve & File
document.querySelectorAll(".pw-btn-approve").forEach((btn) => {
btn.addEventListener("click", async () => {
const order = btn.getAttribute("data-order");
const msg = document.querySelector(`[data-msg-for="${order}"]`);
if (!adminToken) {
msg.className = "pw-msg pw-msg-err";
msg.textContent = "APPROVE_FILE_TOKEN not set on this server — contact ops.";
return;
}
btn.disabled = true;
btn.textContent = "Filing…";
try {
const resp = await fetch(
`/api/v1/compliance-orders/${order}/approve-and-file`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
},
);
const data = await resp.json();
if (resp.ok) {
msg.className = "pw-msg pw-msg-ok";
msg.textContent = "✓ " + (data.message || "Approved & dispatched.");
btn.textContent = "Filed";
} else {
msg.className = "pw-msg pw-msg-err";
msg.textContent = "Error: " + (data.error || resp.status);
btn.disabled = false;
btn.textContent = "Approve & File";
}
} catch (err) {
msg.className = "pw-msg pw-msg-err";
msg.textContent = "Error: " + err.message;
btn.disabled = false;
btn.textContent = "Approve & File";
}
});
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,132 @@
"""
/admin-filings Admin review dashboard.
Lists every compliance order that's paid AND staged for admin review
(auto_filing toggle was off when the handler tried to submit). Shows
packet preview links (pre-signed MinIO URLs) + an Approve & File button
that hits the Express API's approve-and-file endpoint.
Admin-role-gated only users in the Accounting Advisor or System
Manager roles see it.
"""
from __future__ import annotations
import os
import frappe
ADMIN_ROLES = {"Accounting Advisor", "System Manager"}
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/admin-filings"
raise frappe.Redirect
user_roles = set(frappe.get_roles(frappe.session.user) or [])
if not (user_roles & ADMIN_ROLES):
raise frappe.PermissionError(
"You must be in the Accounting Advisor or System Manager role."
)
context.no_cache = 1
context.show_sidebar = True
context.title = "Admin Filings Review"
context.admin_token = (
os.environ.get("APPROVE_FILE_TOKEN", "")
or frappe.conf.get("approve_file_token", "")
)
context.pending_orders = _fetch_pending()
def _fetch_pending():
"""Return compliance_orders in paid + admin-review-pending state."""
database_url = os.environ.get("DATABASE_URL")
if not database_url:
return []
try:
import psycopg2
import psycopg2.extras
except ImportError:
return []
try:
with psycopg2.connect(database_url) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT co.order_number, co.service_slug, co.service_name,
co.customer_email, co.customer_name,
co.created_at, co.paid_at,
co.erpnext_sales_order,
co.deminimis_worksheet_json,
co.deminimis_estimated_contrib_cents,
co.deminimis_result_is_exempt,
co.validation_errors,
co.intake_data_validated,
te.legal_name AS entity_name,
te.frn AS entity_frn,
te.id AS entity_id,
te.line_105_primary,
te.line_105_categories,
te.safe_harbor_election,
te.affiliated_filer_name,
(SELECT COUNT(*) FROM reseller_certifications rc
WHERE rc.filer_telecom_entity_id = te.id
AND rc.status = 'active'
) AS active_reseller_cert_count,
(SELECT COUNT(*) FROM reseller_certifications rc
WHERE rc.filer_telecom_entity_id = te.id
AND rc.status = 'active'
AND rc.renewal_due <= CURRENT_DATE + INTERVAL '90 days'
) AS expiring_soon_cert_count,
(SELECT s.fcc_compliance_ok FROM cdr_traffic_studies s
JOIN cdr_ingestion_profiles p ON p.id = s.profile_id
WHERE p.telecom_entity_id = te.id
ORDER BY s.generated_at DESC LIMIT 1
) AS traffic_study_compliance_ok,
(SELECT s.usac_submitted_at FROM cdr_traffic_studies s
JOIN cdr_ingestion_profiles p ON p.id = s.profile_id
WHERE p.telecom_entity_id = te.id
ORDER BY s.generated_at DESC LIMIT 1
) AS traffic_study_usac_submitted_at,
(SELECT COALESCE(SUM(revenue_cents), 0) FROM icc_revenue_lines icc
JOIN cdr_ingestion_profiles p ON p.id = icc.profile_id
WHERE p.telecom_entity_id = te.id
AND icc.reporting_year = EXTRACT(YEAR FROM CURRENT_DATE)::int - 1
) AS icc_revenue_ytd_cents
FROM compliance_orders co
LEFT JOIN telecom_entities te ON te.id = co.telecom_entity_id
WHERE co.payment_status = 'paid'
-- Paid but we haven't recorded a confirmation yet. Handler
-- either hit the auto_filing gate or failed; either way
-- admin review is appropriate.
AND (co.erpnext_sales_order IS NOT NULL)
ORDER BY co.paid_at DESC NULLS LAST, co.created_at DESC
LIMIT 100
"""
)
raw = cur.fetchall() or []
except Exception as exc:
frappe.log_error(f"admin-filings PG error: {exc}", "admin-filings")
return []
rows = []
for r in raw:
# Pull the generated_files list off the Sales Order if available.
so_name = r.get("erpnext_sales_order")
files = []
try:
if so_name:
so = frappe.get_doc("Sales Order", so_name)
raw_files = (so.get("custom_generated_files") or "").strip()
files = [f for f in raw_files.splitlines() if f.strip()]
except Exception:
pass
rows.append({
**r,
"generated_files": files,
"state": "Awaiting admin review" if not files
else "Packet ready — review + approve",
})
return rows

View file

@ -0,0 +1,171 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
.pw-rescerts-wrap { max-width: 1200px; margin: 0 auto; }
.pw-rescerts-wrap h1 { color: #1a2744; margin: 0 0 1rem; }
.pw-rescerts-summary {
display: flex; gap: 1rem; margin-bottom: 1rem;
}
.pw-summary-card {
flex: 1; padding: 0.75rem 1rem;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
}
.pw-summary-card strong { font-size: 1.4rem; color: #1a2744; }
.pw-summary-card .pw-card-label { color: #64748b; font-size: 0.85rem; }
.pw-rescerts-filter {
margin-bottom: 1rem; padding: 0.5rem 0.75rem;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
display: flex; gap: 0.5rem;
}
.pw-rescerts-filter input { flex: 1; padding: 0.4rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 5px; font-size: 0.9rem; }
.pw-rescerts-filter select { padding: 0.4rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 5px; font-size: 0.9rem; }
.pw-rescerts-tbl { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.pw-rescerts-tbl th, .pw-rescerts-tbl td { padding: 0.55rem 0.75rem; text-align: left; border-bottom: 1px solid #f0f4f8; vertical-align: top; }
.pw-rescerts-tbl th { background: #f8fafc; color: #475569; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
.pw-rescerts-tbl tr[data-status="active"] td { background: #fff; }
.pw-rescerts-tbl tr[data-status="expired"] td { background: #fef3c7; }
.pw-rescerts-tbl tr[data-status="revoked"] td { background: #fee2e2; }
.pw-rescerts-tbl tr[data-due-soon="1"] .pw-due { color: #b91c1c; font-weight: 600; }
.pw-status-pill {
display: inline-block; padding: 0.1rem 0.5rem;
background: #e0e7ff; color: #1e3a8a;
border-radius: 10px; font-size: 0.72rem; font-weight: 600;
}
.pw-status-pill[data-status="active"] { background: #d1fae5; color: #065f46; }
.pw-status-pill[data-status="expired"] { background: #fef3c7; color: #92400e; }
.pw-status-pill[data-status="revoked"] { background: #fee2e2; color: #991b1b; }
.pw-btn-revoke {
padding: 0.25rem 0.6rem; background: #fee2e2; color: #991b1b;
border: 0; border-radius: 4px; font-size: 0.75rem; cursor: pointer;
}
</style>
<div class="pw-rescerts-wrap">
<h1>Reseller Certifications</h1>
<p style="color:#64748b; margin-bottom: 1rem;">
Active certifications supporting Form 499-A Line 303 revenue claims.
Per Section IV.C.4, these must be renewed annually. The renewal
worker emails customers at T-30, T-14, T-7, T-1 days.
</p>
<div class="pw-rescerts-summary">
<div class="pw-summary-card">
<strong>{{ certifications|length }}</strong>
<div class="pw-card-label">total certifications on file</div>
</div>
<div class="pw-summary-card" style="background: #fef3c7;">
<strong>{{ expiring_soon_count }}</strong>
<div class="pw-card-label">expiring within 90 days</div>
</div>
</div>
<div class="pw-rescerts-filter">
<input type="text" id="pw-rc-search" placeholder="Filter by filer, reseller name, filer ID…">
<select id="pw-rc-status-filter">
<option value="">All statuses</option>
<option value="active" selected>Active only</option>
<option value="expired">Expired</option>
<option value="revoked">Revoked</option>
</select>
</div>
<table class="pw-rescerts-tbl">
<thead>
<tr>
<th>Filer (our customer)</th>
<th>Reseller</th>
<th>Reseller Filer ID</th>
<th>Signed</th>
<th class="pw-due">Renewal due</th>
<th>Status</th>
<th>Has signed PDF</th>
<th></th>
</tr>
</thead>
<tbody id="pw-rc-rows">
{% if not certifications %}
<tr><td colspan="8" style="text-align:center; padding: 2rem; color:#64748b;">
No reseller certifications on file yet.
</td></tr>
{% endif %}
{% for c in certifications %}
<tr data-status="{{ c.status }}"
data-due-soon="{{ 1 if c.days_to_renewal is not none and c.days_to_renewal <= 30 else 0 }}"
data-search="{{ (c.filer_legal_name or '')|lower }} {{ c.reseller_legal_name|lower }} {{ c.reseller_filer_id_499 }}">
<td>{{ c.filer_legal_name or '—' }}</td>
<td>
{{ c.reseller_legal_name }}
{% if c.reseller_contact_email %}
<br><span style="color:#64748b; font-size:.78rem;">{{ c.reseller_contact_email }}</span>
{% endif %}
</td>
<td><code>{{ c.reseller_filer_id_499 }}</code></td>
<td>{{ frappe.utils.format_date(c.certification_date) }}</td>
<td class="pw-due">
{{ frappe.utils.format_date(c.renewal_due) }}
{% if c.days_to_renewal is not none %}
<br><span style="font-size:.72rem; color:#64748b;">
{% if c.days_to_renewal < 0 %}{{ -c.days_to_renewal }}d overdue
{% else %}in {{ c.days_to_renewal }}d{% endif %}
</span>
{% endif %}
</td>
<td><span class="pw-status-pill" data-status="{{ c.status }}">{{ c.status }}</span></td>
<td>{% if c.has_signed_pdf %}✓{% else %}—{% endif %}</td>
<td>
{% if c.status == 'active' %}
<button class="pw-btn-revoke" data-cert-id="{{ c.id }}">Revoke</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function () {
const search = document.getElementById("pw-rc-search");
const statusFilter = document.getElementById("pw-rc-status-filter");
function applyFilter() {
const q = (search.value || "").trim().toLowerCase();
const st = statusFilter.value;
document.querySelectorAll("#pw-rc-rows tr[data-search]").forEach((tr) => {
const matchesQ = !q || tr.getAttribute("data-search").includes(q);
const matchesStatus = !st || tr.getAttribute("data-status") === st;
tr.style.display = (matchesQ && matchesStatus) ? "" : "none";
});
}
search?.addEventListener("input", applyFilter);
statusFilter?.addEventListener("change", applyFilter);
applyFilter();
document.querySelectorAll(".pw-btn-revoke").forEach((btn) => {
btn.addEventListener("click", async () => {
const certId = btn.getAttribute("data-cert-id");
if (!confirm("Revoke this reseller certification? The filer can no longer claim Line 303 revenue from this reseller.")) return;
try {
const r = await fetch(`/api/v1/reseller-certs/${certId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "revoked" }),
});
if (r.ok) {
btn.closest("tr").setAttribute("data-status", "revoked");
btn.closest("tr").querySelector(".pw-status-pill").textContent = "revoked";
btn.closest("tr").querySelector(".pw-status-pill").setAttribute("data-status", "revoked");
btn.remove();
applyFilter();
} else {
alert(`Could not revoke: ${r.status}`);
}
} catch (e) {
alert(`Error: ${e.message}`);
}
});
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,78 @@
"""
/admin-resellers Reseller certification dashboard.
Lists every `reseller_certifications` row across all telecom_entities,
grouped by renewal month. Admin-role-gated. Used to audit Line 303
coverage: every filer reporting Line 303 revenue needs a signed
certification from every reseller customer, renewed annually.
"""
from __future__ import annotations
import os
import frappe
ADMIN_ROLES = {"Accounting Advisor", "System Manager"}
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/admin-resellers"
raise frappe.Redirect
user_roles = set(frappe.get_roles(frappe.session.user) or [])
if not (user_roles & ADMIN_ROLES):
raise frappe.PermissionError(
"You must be in the Accounting Advisor or System Manager role."
)
context.no_cache = 1
context.show_sidebar = True
context.title = "Reseller Certifications"
context.certifications = _fetch_certifications()
context.expiring_soon_count = sum(
1 for c in context.certifications
if c.get("days_to_renewal") is not None and c["days_to_renewal"] <= 90
)
def _fetch_certifications():
database_url = os.environ.get("DATABASE_URL")
if not database_url:
return []
try:
import psycopg2
import psycopg2.extras
except ImportError:
return []
try:
with psycopg2.connect(database_url) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT rc.id,
rc.filer_telecom_entity_id,
rc.reseller_filer_id_499,
rc.reseller_legal_name,
rc.reseller_contact_email,
rc.certification_date,
rc.renewal_due,
rc.status,
rc.reporting_year_first,
te.legal_name AS filer_legal_name,
te.customer_id,
(rc.renewal_due - CURRENT_DATE) AS days_to_renewal,
(rc.certification_minio_path IS NOT NULL) AS has_signed_pdf
FROM reseller_certifications rc
JOIN telecom_entities te ON te.id = rc.filer_telecom_entity_id
ORDER BY
CASE rc.status WHEN 'active' THEN 0 ELSE 1 END,
rc.renewal_due ASC
LIMIT 500
"""
)
return cur.fetchall() or []
except Exception as exc:
frappe.log_error(f"admin-resellers PG error: {exc}", "admin-resellers")
return []

View file

@ -0,0 +1,130 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
.pw-cdr-wrap { max-width: 900px; margin: 0 auto; }
.pw-cdr-wrap h1 { color: #1e3a5f; margin: 0 0 1rem; }
.pw-cdr-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem 1.5rem; margin: 1rem 0; }
.pw-cdr-card h2 { color: #1e3a5f; font-size: 1.15rem; margin: 0 0 .75rem; }
.pw-table { width: 100%; border-collapse: collapse; font-size: .9rem; }
.pw-table th, .pw-table td { padding: .5rem .7rem; border-bottom: 1px solid #f0f4f8; text-align: left; }
.pw-pill {
padding: .1rem .5rem; border-radius: 10px; font-weight: 600; font-size: .75rem;
}
.pw-pill-w { background: #dbeafe; color: #1e40af; }
.pw-pill-r { background: #dcfce7; color: #166534; }
.pw-pill-u { background: #f1f5f9; color: #475569; }
.pw-btn { padding: .6rem 1.25rem; border: 0; border-radius: 6px; background: #059669; color: #fff; font-weight: 600; cursor: pointer; }
</style>
<div class="pw-cdr-wrap">
<h1>Wholesale / Retail Mapping</h1>
<p>
Tag each of your trunk groups and customer account IDs as
<strong>wholesale</strong> or <strong>retail</strong>. The 499-A handler uses this
to split revenue between Block 3 (carrier-to-carrier) and Block 4-A
(end-user). Calls whose trunk group / account isn&apos;t tagged show up
as <em>unknown</em> &mdash; tag them so your study is complete.
</p>
{% if not profile %}
<div class="pw-cdr-card">
<p>No CDR profile yet &mdash; <a href="/cdr-settings">configure ingestion first</a>.</p>
</div>
{% else %}
<div class="pw-cdr-card">
<h2>Trunk groups ({{ trunk_groups|length }})</h2>
{% if not trunk_groups %}
<p>No trunk groups seen in the last 90 days yet. Upload some CDRs or wait for the puller's next run.</p>
{% else %}
<table class="pw-table">
<thead><tr><th>Trunk Group ID</th><th>Bucket</th></tr></thead>
<tbody>
{% for tg in trunk_groups %}
{% set current = "unknown" %}
{% for m in existing if m.match_type == "trunk_group" and m.match_value == tg %}
{% set current = m.bucket %}
{% endfor %}
<tr>
<td><code>{{ tg }}</code></td>
<td>
<select data-match-type="trunk_group" data-match-value="{{ tg }}" class="bucket-select">
<option value="" {% if current == "unknown" %}selected{% endif %}>Unknown</option>
<option value="wholesale" {% if current == "wholesale" %}selected{% endif %}>Wholesale</option>
<option value="retail" {% if current == "retail" %}selected{% endif %}>Retail</option>
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div class="pw-cdr-card">
<h2>Customer account IDs ({{ account_ids|length }})</h2>
{% if not account_ids %}
<p>No distinct customer account IDs seen in the last 90 days.</p>
{% else %}
<table class="pw-table">
<thead><tr><th>Account ID</th><th>Bucket</th></tr></thead>
<tbody>
{% for aid in account_ids %}
{% set current = "unknown" %}
{% for m in existing if m.match_type == "account_id" and m.match_value == aid %}
{% set current = m.bucket %}
{% endfor %}
<tr>
<td><code>{{ aid }}</code></td>
<td>
<select data-match-type="account_id" data-match-value="{{ aid }}" class="bucket-select">
<option value="" {% if current == "unknown" %}selected{% endif %}>Unknown</option>
<option value="wholesale" {% if current == "wholesale" %}selected{% endif %}>Wholesale</option>
<option value="retail" {% if current == "retail" %}selected{% endif %}>Retail</option>
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div style="text-align: right; margin: 2rem 0;">
<button id="save_mappings" class="pw-btn">Save mappings</button>
</div>
{% endif %}
</div>
<script>
(function () {
const btn = document.getElementById("save_mappings");
if (!btn) return;
const profileId = {{ profile.id if profile else "null" }};
btn.addEventListener("click", async () => {
const mappings = [];
document.querySelectorAll(".bucket-select").forEach((sel) => {
const bucket = sel.value;
if (!bucket) return;
mappings.push({
match_type: sel.dataset.matchType,
match_value: sel.dataset.matchValue,
bucket: bucket,
});
});
try {
const resp = await fetch(`/api/v1/cdr/profile/${profileId}/bucket-mappings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ mappings }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
alert(`Saved ${mappings.length} mapping(s).`);
} catch (err) {
alert("Save failed: " + err.message);
}
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,98 @@
"""
/cdr-buckets Portal page to tag trunk groups & account IDs as wholesale/retail.
Lists every distinct trunk_group_id and customer_account_id seen in the
carrier's last 90 days of CDRs; admin tags each as wholesale or retail.
Mappings persist to ``cdr_bucket_mappings`` and drive 499-A Block 3 vs.
Block 4-A attribution.
"""
import frappe
import os
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/cdr-buckets"
raise frappe.Redirect
context.no_cache = 1
context.show_sidebar = True
context.title = "Wholesale / Retail Mapping"
profile = _load_profile(frappe.session.user)
context.profile = profile
if not profile:
context.trunk_groups = []
context.account_ids = []
context.existing = []
return
context.trunk_groups, context.account_ids = _distinct_identifiers(profile["id"])
context.existing = _existing_mappings(profile["id"])
def _load_profile(user_email: str):
try:
import psycopg2
import psycopg2.extras
with psycopg2.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT p.*
FROM cdr_ingestion_profiles p
JOIN customers c ON c.id = p.customer_id
WHERE LOWER(c.email) = LOWER(%s)
ORDER BY p.id DESC LIMIT 1
""",
(user_email,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception as exc:
frappe.log_error(f"cdr-buckets profile lookup failed: {exc}", "cdr-buckets")
return None
def _distinct_identifiers(profile_id: int):
import psycopg2
import psycopg2.extras
try:
with psycopg2.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT DISTINCT trunk_group_id FROM cdr_calls "
"WHERE profile_id=%s AND trunk_group_id IS NOT NULL "
"AND start_time > NOW() - INTERVAL '90 days' "
"ORDER BY trunk_group_id LIMIT 500",
(profile_id,),
)
trunks = [r["trunk_group_id"] for r in cur.fetchall()]
cur.execute(
"SELECT DISTINCT customer_account_id FROM cdr_calls "
"WHERE profile_id=%s AND customer_account_id IS NOT NULL "
"AND start_time > NOW() - INTERVAL '90 days' "
"ORDER BY customer_account_id LIMIT 500",
(profile_id,),
)
accts = [r["customer_account_id"] for r in cur.fetchall()]
return trunks, accts
except Exception:
return [], []
def _existing_mappings(profile_id: int):
import psycopg2
import psycopg2.extras
try:
with psycopg2.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT match_type, match_value, bucket FROM cdr_bucket_mappings "
"WHERE profile_id=%s",
(profile_id,),
)
return [dict(r) for r in cur.fetchall()]
except Exception:
return []

View file

@ -0,0 +1,246 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
:root { --pw-navy:#1e3a5f; --pw-blue:#2d4e78; --pw-light:#f8fafc; --pw-green:#059669; --pw-amber:#b45309; }
.pw-cdr-wrap { max-width: 900px; margin: 0 auto; }
.pw-cdr-wrap h1 { color: var(--pw-navy); margin: 0 0 1rem; }
.pw-cdr-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem 1.5rem; margin: 1rem 0; }
.pw-cdr-card h2 { color: var(--pw-navy); font-size: 1.2rem; margin: 0 0 .75rem; }
.pw-field { display: block; margin: 1rem 0 .25rem; font-weight: 600; color: #1f2937; }
.pw-input { width: 100%; padding: .55rem .7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: .95rem; }
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 250px; }
.pw-btn { padding: .6rem 1.25rem; border: 0; border-radius: 6px; background: var(--pw-green); color: #fff; font-weight: 600; cursor: pointer; }
.pw-btn-secondary { background: var(--pw-blue); }
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-callout { border-left: 4px solid var(--pw-blue); background: #f0f9ff; padding: .9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
.pw-help { color: #64748b; font-size: .85rem; margin-top: .2rem; }
.pw-badge-ok { background: #d1fae5; color: #065f46; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 600; }
.pw-badge-off { background: #e2e8f0; color: #475569; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 600; }
.pw-badge-warn { background: #fef3c7; color: #92400e; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 600; }
.pw-quota-bar { height: 10px; background: #e2e8f0; border-radius: 5px; overflow: hidden; margin: .25rem 0; }
.pw-quota-fill { height: 100%; background: var(--pw-blue); transition: width .3s; }
.pw-quota-fill.warn { background: var(--pw-amber); }
.pw-quota-fill.over { background: #b91c1c; }
</style>
<div class="pw-cdr-wrap">
<h1>CDR Ingestion Settings</h1>
{% if not profile %}
<div class="pw-callout">
<strong>No CDR profile yet.</strong> Set up your carrier&apos;s CDR
ingestion and we&apos;ll start classifying calls for your 499-A traffic
study. You control whether you push CDRs to us, let us pull them from
your switch, or both.
</div>
{% endif %}
<div class="pw-cdr-card">
<h2>1. Your switch</h2>
<label class="pw-field" for="switch_preset">What switch do you use?</label>
<select id="switch_preset" class="pw-input" name="switch_preset">
{% for p in switch_presets %}
<option value="{{ p.slug }}"
{% if profile and profile.switch_preset == p.slug %}selected{% endif %}>
{{ p.label }}
</option>
{% endfor %}
</select>
<p class="pw-help">
Picking a known switch auto-selects the CDR format and shows you the
credential fields we need. Click <em>Test connection</em> before saving
to make sure the credentials work.
</p>
<div id="preset_credentials">
<!-- populated dynamically from /api/v1/cdr/preset-fields/:slug -->
</div>
<div style="margin-top: 1rem;">
<button class="pw-btn-plain pw-btn" id="test_connection">Test connection</button>
<span id="test_result" style="margin-left: .75rem; font-size: .9rem;"></span>
</div>
</div>
<div class="pw-cdr-card">
<h2>2. Secure file drop (SFTP / FTPS)</h2>
<p>
Enable a secure SFTP / FTPS endpoint scoped to your account. We generate a
random password when you turn it on &mdash; <strong>copied once</strong>.
Use this alongside switch pull, or as a standalone option.
</p>
<p>
Status:
{% if profile and profile.sftpgo_enabled %}
<span class="pw-badge-ok">Enabled</span>
<code>{{ profile.sftpgo_username }}</code>
{% else %}
<span class="pw-badge-off">Disabled</span>
{% endif %}
</p>
<button id="sftp_toggle" class="pw-btn"
data-enabled="{{ '1' if (profile and profile.sftpgo_enabled) else '0' }}">
{% if profile and profile.sftpgo_enabled %}Rotate password{% else %}Enable secure file drop{% endif %}
</button>
</div>
<div class="pw-cdr-card">
<h2>3. Revenue attribution</h2>
<label>
<input type="checkbox" id="minutes_only"
{% if profile and profile.minutes_only_estimation_enabled %}checked{% endif %}>
Use minutes-only estimation
</label>
<p class="pw-help">
Leave this <strong>off</strong> if your CDRs carry per-call revenue
(recommended &mdash; most commercial switches do). Turn it on if you
run a flat-rate line service or your switch doesn&apos;t emit per-call
charges. Your traffic study will be labeled accordingly.
</p>
<label class="pw-field" for="flat_monthly_revenue">Monthly flat revenue (USD)</label>
<input id="flat_monthly_revenue" type="number" step="1" class="pw-input"
value="{{ (profile.flat_monthly_revenue_cents / 100) if (profile and profile.flat_monthly_revenue_cents) else '' }}"
placeholder="only needed when minutes-only is on">
</div>
<div class="pw-cdr-card">
<h2>4. Storage &amp; retention</h2>
{% if profile %}
<p>Current plan: <strong>{{ profile.storage_plan|title }}</strong></p>
<!-- Usage meter populated asynchronously -->
<div id="quota_meter">Loading usage…</div>
<p class="pw-help">At 80% we email a heads-up. At 100%, behavior follows the over-quota policy below.</p>
<label class="pw-field" for="over_quota_policy">Over-quota policy</label>
<select id="over_quota_policy" class="pw-input">
<option value="notify" {% if profile.over_quota_policy == "notify" %}selected{% endif %}>Pause classification, notify admin</option>
<option value="block" {% if profile.over_quota_policy == "block" %}selected{% endif %}>Reject new uploads until upgraded</option>
<option value="auto_upgrade" {% if profile.over_quota_policy == "auto_upgrade" %}selected{% endif %}>Auto-upgrade to next tier (charges saved card)</option>
</select>
{% else %}
<p>Storage quotas activate once your profile is saved. You&apos;re on the <em>Included with filing</em> plan by default.</p>
{% endif %}
<table style="width: 100%; margin-top: 1rem; font-size: .9rem; border-collapse: collapse;">
<thead style="background: var(--pw-light);">
<tr>
<th style="text-align:left; padding:.4rem .6rem;">Plan</th>
<th style="text-align:left; padding:.4rem .6rem;">Storage</th>
<th style="text-align:left; padding:.4rem .6rem;">Calls / year</th>
<th style="text-align:left; padding:.4rem .6rem;">Annual</th>
</tr>
</thead>
<tbody>
{% for t in storage_tiers %}
<tr>
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.name }}</td>
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.storage }}</td>
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.calls }}</td>
<td style="padding:.4rem .6rem; border-top:1px solid #e2e8f0;">{{ t.price }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="margin: 2rem 0; text-align: right;">
<button id="save_settings" class="pw-btn">Save settings</button>
</div>
</div>
<script>
(function () {
const profileId = {{ profile.id if profile else 'null' }};
async function api(method, path, body) {
const opts = { method, headers: { "Content-Type": "application/json" }, credentials: "same-origin" };
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(path, opts);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
// Load quota meter once the page is ready
async function loadUsage() {
if (!profileId) return;
try {
const year = new Date().getUTCFullYear();
const resp = await fetch(`/api/v1/cdr/profile/${profileId}/study?year=${year}`);
const data = await resp.json();
const ing = data.ingestion || {};
const el = document.getElementById("quota_meter");
if (!el) return;
// Without per-plan caps surfaced, show raw counts.
el.innerHTML = `
<div><strong>${(ing.rows_this_year || 0).toLocaleString()}</strong> calls classified in ${year}</div>
<div><strong>${((ing.bytes_stored || 0) / 1073741824).toFixed(2)} GB</strong> of compressed CDR data on file</div>
<div>Last ingest: ${ing.last_upload_at || "—"}</div>
`;
} catch (err) { /* silent */ }
}
loadUsage();
// Test connection button: fires against a hypothetical preset-validate
// endpoint; wired to the cdr.ts route once implemented.
document.getElementById("test_connection").addEventListener("click", async () => {
const msg = document.getElementById("test_result");
msg.textContent = "Testing…";
msg.style.color = "#64748b";
try {
const preset = document.getElementById("switch_preset").value;
const resp = await api("POST", `/api/v1/cdr/profile/${profileId || 0}/test-connection`, {
switch_preset: preset,
});
if (resp.ok) {
msg.textContent = "✓ " + (resp.detail || "connected");
msg.style.color = "#059669";
} else {
msg.textContent = "✗ " + (resp.detail || "failed");
msg.style.color = "#b91c1c";
}
} catch (err) {
msg.textContent = "✗ " + err.message;
msg.style.color = "#b91c1c";
}
});
// SFTP toggle
document.getElementById("sftp_toggle").addEventListener("click", async (e) => {
const enabled = e.target.dataset.enabled === "1";
try {
const resp = await api("POST", `/api/v1/cdr/profile/${profileId}/sftpgo`,
{ action: enabled ? "rotate" : "enable" });
if (resp.password) {
alert(
"SFTP credentials — copy now, we won't show this again:\n\n" +
`Host: cdr.performancewest.net\nUser: ${resp.username}\nPass: ${resp.password}\n\n` +
"Clients: SFTP port 2022, FTPS port 990 (implicit TLS).",
);
window.location.reload();
}
} catch (err) {
alert("Could not toggle SFTP: " + err.message);
}
});
// Save settings
document.getElementById("save_settings").addEventListener("click", async () => {
const body = {
switch_preset: document.getElementById("switch_preset").value,
minutes_only_estimation_enabled: document.getElementById("minutes_only").checked,
flat_monthly_revenue_cents: Math.round(
(parseFloat(document.getElementById("flat_monthly_revenue").value) || 0) * 100
),
over_quota_policy: document.getElementById("over_quota_policy")?.value,
};
try {
await api("PUT", `/api/v1/cdr/profile/${profileId}`, body);
alert("Saved.");
} catch (err) {
alert("Save failed: " + err.message);
}
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,82 @@
"""
/cdr-settings Portal page to configure CDR ingestion.
Lets the customer pick their switch from the dropdown, plug in credentials,
test the connection, and toggle the SFTPGo push option on/off.
Actual CRUD happens via the Express API (`api/src/routes/cdr.ts`); this
page renders the form state + handles submission.
"""
import frappe
import os
# Presets mirror scripts/workers/cdr_presets/__init__.py — intentionally
# duplicated here rather than imported, so the Frappe bench doesn't have
# to load the worker package.
SWITCH_PRESETS = [
{"slug": "other", "label": "Other (configure manually)"},
{"slug": "netsapiens", "label": "NetSapiens"},
{"slug": "metaswitch", "label": "Metaswitch iCM (Provisioning Server)"},
{"slug": "freeswitch", "label": "FreeSWITCH (mod_cdr_csv)"},
{"slug": "asterisk", "label": "Asterisk / AsteriskNOW / FreePBX"},
{"slug": "ribbon", "label": "Ribbon / Sonus SBC (EMA / PSX)"},
{"slug": "sansay", "label": "Sansay SBC (SSM)"},
{"slug": "broadworks", "label": "Cisco BroadWorks (OCS)"},
{"slug": "kazoo", "label": "2600Hz Kazoo"},
{"slug": "grandstream", "label": "Grandstream UCM (62xx / 63xx)"},
{"slug": "fortysix_labs", "label": "46Labs (Peering / NOVA)"},
{"slug": "sip_navigator", "label": "SIP Navigator (Cataleya Orchid One)"},
]
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/cdr-settings"
raise frappe.Redirect
context.no_cache = 1
context.show_sidebar = True
context.title = "CDR Settings"
context.switch_presets = SWITCH_PRESETS
# Look up the customer's current profile (if any) via the API layer DB.
context.profile = _load_profile_for_user(frappe.session.user)
# Pricing + retention messaging (mirror the marketing page)
context.storage_tiers = [
{"name": "Included with filing", "storage": "10 GB", "calls": "10 M", "price": ""},
{"name": "Storage Tier 1", "storage": "50 GB", "calls": "50 M", "price": "$99 /yr"},
{"name": "Storage Tier 2", "storage": "250 GB","calls": "250 M", "price": "$299 /yr"},
{"name": "Storage Tier 3", "storage": "1 TB", "calls": "1 B", "price": "$799 /yr"},
]
def _load_profile_for_user(user_email: str):
database_url = os.environ.get("DATABASE_URL")
if not database_url:
return None
try:
import psycopg2
import psycopg2.extras
except ImportError:
return None
try:
with psycopg2.connect(database_url) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT p.*, te.legal_name AS entity_name, te.frn
FROM cdr_ingestion_profiles p
JOIN telecom_entities te ON te.id = p.telecom_entity_id
JOIN customers c ON c.id = p.customer_id
WHERE LOWER(c.email) = LOWER(%s)
ORDER BY p.id DESC LIMIT 1
""",
(user_email,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception as exc:
frappe.log_error(f"cdr-settings PG query failed: {exc}", "cdr-settings")
return None

View file

@ -0,0 +1,178 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
.pw-cdr-wrap { max-width: 860px; margin: 0 auto; }
.pw-cdr-wrap h1 { color: #1e3a5f; margin: 0 0 1rem; }
.pw-cdr-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem 1.5rem; margin: 1rem 0; }
.pw-drop {
border: 2px dashed #cbd5e1; border-radius: 12px; padding: 2rem 1rem;
text-align: center; color: #475569; background: #f8fafc;
}
.pw-drop.drag { border-color: #059669; background: #ecfdf5; color: #065f46; }
.pw-lock-banner {
background: #fef3c7; border-left: 4px solid #b45309;
padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin: 1rem 0;
}
.pw-unlock-banner {
background: #ecfdf5; border-left: 4px solid #059669;
padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin: 1rem 0;
}
.pw-stat-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.pw-stat { flex: 1 1 180px; background: #f8fafc; padding: 1rem; border-radius: 8px; }
.pw-stat strong { font-size: 1.4rem; color: #1e3a5f; display: block; }
.pw-btn { padding: .6rem 1.25rem; border: 0; border-radius: 6px; background: #059669; color: #fff; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
</style>
<div class="pw-cdr-wrap">
<h1>CDR Upload &amp; Traffic Study</h1>
{% if not profile %}
<div class="pw-cdr-card">
<p>No CDR ingestion profile yet.</p>
<p><a href="/cdr-settings" class="pw-btn">Set up ingestion →</a></p>
</div>
{% else %}
<div class="pw-cdr-card">
<h2 style="color:#1e3a5f; font-size:1.2rem; margin:0 0 .75rem;">Drop a CDR file</h2>
<div id="pw_drop" class="pw-drop">
<p>Drag &amp; drop a CDR (CSV, NDJSON, CSV.gz) here, or click to choose.</p>
<input id="pw_file" type="file" style="display:none" accept=".csv,.csv.gz,.ndjson,.json,.gz,.txt">
<button class="pw-btn" onclick="document.getElementById('pw_file').click()">Choose file</button>
</div>
<p style="margin-top:1rem; font-size:.85rem; color:#64748b;">
Upload goes straight to encrypted storage scoped to your account.
Processing starts within a minute. Files &gt; 500 MB: use the
secure file drop (SFTP/FTPS) from <a href="/cdr-settings">settings</a>.
</p>
</div>
<div id="pw_study" class="pw-cdr-card">
<h2 style="color:#1e3a5f; font-size:1.2rem; margin:0 0 .75rem;">
{{ reporting_year }} traffic study
</h2>
<div id="pw_study_body">Loading…</div>
</div>
{% endif %}
</div>
<script>
(function () {
const profileId = {{ profile.id if profile else "null" }};
const year = {{ reporting_year }};
if (!profileId) return;
// ── Drag-drop upload ──────────────────────────────────────────────
const drop = document.getElementById("pw_drop");
const fileInput = document.getElementById("pw_file");
["dragenter","dragover"].forEach((e) =>
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("drag"); }));
["dragleave","drop"].forEach((e) =>
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("drag"); }));
drop.addEventListener("drop", (ev) => { if (ev.dataTransfer.files[0]) uploadFile(ev.dataTransfer.files[0]); });
fileInput.addEventListener("change", () => { if (fileInput.files[0]) uploadFile(fileInput.files[0]); });
async function uploadFile(file) {
drop.innerHTML = `<p>Uploading ${file.name}…</p>`;
try {
const tokenResp = await fetch("/api/v1/cdr/upload-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ profile_id: profileId, file_name: file.name }),
});
const token = await tokenResp.json();
// Actual PUT would use the pre-signed MinIO URL; the API layer
// constructs it from token. Here we proxy through the same API.
const putResp = await fetch(`/api/v1/cdr/upload/${token.token}`, {
method: "PUT",
body: file,
});
if (!putResp.ok) throw new Error(`HTTP ${putResp.status}`);
drop.innerHTML = `<p style="color:#065f46;">✓ ${file.name} uploaded.
Processing in queue; refresh in a minute to see the study update.</p>`;
setTimeout(loadStudy, 30000);
} catch (err) {
drop.innerHTML = `<p style="color:#b91c1c;">Upload failed: ${err.message}</p>`;
}
}
// ── Paywall-aware study render ────────────────────────────────────
async function loadStudy() {
const body = document.getElementById("pw_study_body");
try {
const resp = await fetch(`/api/v1/cdr/profile/${profileId}/study?year=${year}`);
const data = await resp.json();
const ing = data.ingestion || {};
const healthBlock = `
<div class="pw-stat-row">
<div class="pw-stat">
<strong>${(ing.rows_this_year || 0).toLocaleString()}</strong>
calls classified in ${year}
</div>
<div class="pw-stat">
<strong>${((ing.bytes_stored || 0) / 1073741824).toFixed(2)} GB</strong>
compressed CDR data on file
</div>
<div class="pw-stat">
<strong>${ing.rows_quarantined || 0}</strong>
rows in quarantine
<a href="/cdr-quarantine" style="font-size:.85rem">review →</a>
</div>
</div>
`;
if (data.status === "locked") {
body.innerHTML = healthBlock + `
<div class="pw-lock-banner">
<strong>${year} classified study is locked.</strong>
<p style="margin:.3rem 0 .75rem;">${data.unlock_reason}</p>
<a class="pw-btn" href="${data.unlock_url}">Unlock ${year} traffic study →</a>
</div>
`;
return;
}
if (data.status === "unlocked_pending_study") {
body.innerHTML = healthBlock + `
<div class="pw-unlock-banner">
<strong>Unlocked.</strong> Your ${year} study will generate on the next CDRAnalysisHandler run — usually within 24 hours of payment.
</div>
`;
return;
}
// Unlocked
const rep = data.classified_report || {};
body.innerHTML = healthBlock + `
<div class="pw-unlock-banner">
<strong>${year} study unlocked</strong> (paid via order ${data.granted_by_order}).
</div>
<table style="width:100%; border-collapse:collapse; margin-top:1rem;">
<thead style="background:#f8fafc;">
<tr><th style="text-align:left;padding:.4rem .6rem;">Metric</th>
<th style="text-align:right;padding:.4rem .6rem;">Revenue-weighted</th>
<th style="text-align:right;padding:.4rem .6rem;">Minutes-weighted</th></tr>
</thead>
<tbody>
${["interstate","intrastate","international","indeterminate"].map((j) => `
<tr><td style="padding:.4rem .6rem;">${j[0].toUpperCase() + j.slice(1)}</td>
<td style="text-align:right;padding:.4rem .6rem;">${fmt(rep[j+"_pct"])}</td>
<td style="text-align:right;padding:.4rem .6rem;">${fmt(rep[j+"_pct_minutes"])}</td></tr>
`).join("")}
</tbody>
</table>
<p style="margin-top:1rem;">
${rep.pdf_minio_path
? `<a class="pw-btn" href="/api/v1/cdr/study-download?profile=${profileId}&year=${year}&fmt=pdf">Download PDF</a>
<a class="pw-btn" style="background:#2d4e78;margin-left:.5rem;" href="/api/v1/cdr/study-download?profile=${profileId}&year=${year}&fmt=xlsx">Download XLSX</a>`
: "<em>Study PDF/XLSX being generated…</em>"}
</p>
`;
} catch (err) {
body.innerHTML = `<p style="color:#b91c1c;">Could not load study: ${err.message}</p>`;
}
}
function fmt(n) { return (n == null) ? "—" : (Number(n).toFixed(2) + "%"); }
loadStudy();
})();
</script>
{% endblock %}

View file

@ -0,0 +1,46 @@
"""
/cdr-upload Portal page: drag-and-drop CDR upload + paywall-aware study preview.
Browser uploads go directly to MinIO via a presigned PUT URL fetched
from `/api/v1/cdr/upload-token`. The page also renders the current
reporting-year study (locked or unlocked per the paywall).
"""
import frappe
import os
from datetime import datetime
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/cdr-upload"
raise frappe.Redirect
context.no_cache = 1
context.show_sidebar = True
context.title = "CDR Upload & Traffic Study"
context.reporting_year = datetime.utcnow().year
context.profile = _load_profile(frappe.session.user)
def _load_profile(user_email: str):
try:
import psycopg2
import psycopg2.extras
with psycopg2.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT p.*, te.legal_name AS entity_name
FROM cdr_ingestion_profiles p
JOIN telecom_entities te ON te.id = p.telecom_entity_id
JOIN customers c ON c.id = p.customer_id
WHERE LOWER(c.email) = LOWER(%s)
ORDER BY p.id DESC LIMIT 1
""",
(user_email,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception:
return None

View file

@ -0,0 +1,300 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
/* Performance West portal — order status pipeline */
:root {
--pw-navy: #1e3a5f;
--pw-blue: #2d4e78;
--pw-light: #e8f0f9;
}
.pw-orders-header {
background: var(--pw-navy);
color: #fff;
padding: 2rem 1.5rem 1.5rem;
border-radius: 12px;
margin-bottom: 1.5rem;
}
.pw-orders-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 700; }
.pw-orders-header p { margin: 0; opacity: 0.8; font-size: 0.9rem; }
.pw-order-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
margin-bottom: 1.5rem;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.pw-order-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #f0f4f8;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: .5rem;
}
.pw-order-id { font-size: .8rem; color: #64748b; font-family: monospace; }
.pw-order-type { font-size: .75rem; font-weight: 600; background: var(--pw-light);
color: var(--pw-navy); padding: .2rem .6rem; border-radius: 99px; }
.pw-order-total { font-weight: 700; color: var(--pw-navy); }
/* Progress bar */
.pw-progress-wrap { padding: 1rem 1.25rem 0; }
.pw-progress-track {
height: 6px; background: #e2e8f0; border-radius: 99px; overflow: hidden; margin-bottom: .75rem;
}
.pw-progress-fill { height: 100%; background: var(--pw-blue); border-radius: 99px; transition: width .4s; }
.pw-state-label { font-size: .85rem; font-weight: 600; color: var(--pw-navy); margin-bottom: .25rem; }
/* Steps */
.pw-steps { display: flex; flex-wrap: wrap; gap: .4rem; padding: .75rem 1.25rem 1rem; }
.pw-step {
display: flex; align-items: center; gap: .35rem;
font-size: .75rem; padding: .2rem .55rem; border-radius: 99px; font-weight: 500;
}
.pw-step.completed { background: #dcfce7; color: #166534; }
.pw-step.active { background: var(--pw-light); color: var(--pw-navy); border: 1.5px solid var(--pw-blue); }
.pw-step.pending { background: #f8fafc; color: #94a3b8; }
.pw-step .dot {
width: 6px; height: 6px; border-radius: 50%;
background: currentColor; flex-shrink: 0;
}
/* Action button */
.pw-action-wrap { padding: 0 1.25rem 1rem; }
.pw-action-btn {
display: inline-flex; align-items: center; gap: .4rem;
background: var(--pw-navy); color: #fff;
padding: .55rem 1.1rem; border-radius: 8px;
text-decoration: none; font-size: .85rem; font-weight: 600;
transition: background .15s;
}
.pw-action-btn:hover { background: var(--pw-blue); color: #fff; }
/* Invoices */
.pw-invoices { padding: 0 1.25rem 1rem; }
.pw-invoices-title { font-size: .75rem; font-weight: 700; color: #64748b;
text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; }
.pw-invoice-row {
display: flex; align-items: center; justify-content: space-between;
font-size: .8rem; padding: .3rem 0; border-top: 1px solid #f1f5f9;
}
.pw-invoice-paid { color: #16a34a; font-weight: 600; }
.pw-invoice-unpaid { color: #dc2626; font-weight: 600; }
.pw-invoice-partial { color: #d97706; font-weight: 600; }
/* Delivered badge */
.pw-delivered-badge {
display: inline-flex; align-items: center; gap: .4rem;
background: #dcfce7; color: #166534; border-radius: 8px;
padding: .4rem .85rem; font-size: .8rem; font-weight: 600; margin: 0 1.25rem 1rem;
}
/* Empty state */
.pw-empty { text-align: center; padding: 3rem 1rem; color: #94a3b8; }
.pw-empty h3 { color: #475569; margin-bottom: .5rem; }
</style>
<div class="container" style="max-width:860px; padding-top:1.5rem; padding-bottom:3rem;">
<div class="pw-orders-header">
<h1>My Orders</h1>
<p>Track your service orders and download documents.</p>
</div>
{% if message %}
<div class="alert alert-warning">{{ message }}</div>
{% endif %}
{% if not has_orders %}
<div class="pw-empty">
<h3>No orders found</h3>
<p>You don't have any orders yet. <a href="https://performancewest.net/services/telecom/canada-crtc">View services →</a></p>
</div>
{% endif %}
{% for order in orders %}
<div class="pw-order-card">
<!-- Header -->
<div class="pw-order-header">
<div>
<span class="pw-order-type">{{ order.type_label }}</span>
<div class="pw-order-id" style="margin-top:.3rem;">
Order {{ order.ext_id }}
{% if order.date %} &middot; {{ frappe.utils.formatdate(order.date, "MMM d, yyyy") }}{% endif %}
</div>
</div>
<div class="pw-order-total">
${{ "{:,.2f}".format(order.total | float) }} USD
{% if order.gateway %}<span style="font-size:.75rem;font-weight:400;color:#64748b;margin-left:.35rem;">via {{ order.gateway }}</span>{% endif %}
</div>
</div>
<!-- Progress (CRTC orders only) -->
{% if order.steps %}
<div class="pw-progress-wrap">
<div class="pw-state-label">
{% for step in order.steps %}{% if step.status == 'active' %}{{ step.label }}{% endif %}{% endfor %}
</div>
<div class="pw-progress-track">
<div class="pw-progress-fill" style="width:{{ order.pct }}%;"></div>
</div>
</div>
<div class="pw-steps">
{% for step in order.steps %}
<div class="pw-step {{ step.status }}">
<span class="dot"></span>
{{ step.label }}
</div>
{% endfor %}
</div>
{% else %}
<!-- Non-CRTC: plain state badge -->
<div style="padding:.75rem 1.25rem;">
<span style="font-size:.85rem;font-weight:600;color:var(--pw-navy);">{{ order.state }}</span>
</div>
{% endif %}
<!-- Delivered badge -->
{% if order.is_delivered %}
<div class="pw-delivered-badge">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Order complete — binder delivered
</div>
{% endif %}
<!-- Action button -->
{% if order.action_url %}
<div class="pw-action-wrap">
<a class="pw-action-btn" href="{{ order.action_url }}">
{{ order.action_label }}
</a>
</div>
{% endif %}
<!-- Registered office address -->
{% if order.address %}
<div style="padding:0 1.25rem .75rem; font-size:.8rem; color:#64748b;">
<strong>Registered office:</strong> {{ order.address }}
</div>
{% endif %}
<!-- Invoices -->
{% if order.invoices %}
<div class="pw-invoices">
<div class="pw-invoices-title">Invoices</div>
{% for inv in order.invoices %}
<div class="pw-invoice-row">
<a href="/Sales Invoice/{{ inv.name }}" style="color:var(--pw-blue);text-decoration:underline;">{{ inv.name }}</a>
<span>
${{ "{:,.2f}".format(inv.grand_total | float) }} —
{% if inv.outstanding_amount == 0 %}
<span class="pw-invoice-paid">Paid</span>
{% elif inv.outstanding_amount < inv.grand_total %}
<span class="pw-invoice-partial">Partially Paid</span>
{% else %}
<span class="pw-invoice-unpaid">Unpaid</span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% if has_compliance %}
<h2 style="margin:2.25rem 0 1rem;color:var(--pw-navy);font-size:1.15rem;">
FCC Compliance
</h2>
<style>
.pw-compliance-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.pw-compliance-table th, .pw-compliance-table td {
padding: .7rem 1rem;
text-align: left;
font-size: .88rem;
border-bottom: 1px solid #f0f4f8;
}
.pw-compliance-table th {
background: #f8fafc;
color: #334155;
font-weight: 600;
font-size: .78rem;
text-transform: uppercase;
letter-spacing: .02em;
}
.pw-compliance-table tr:last-child td { border-bottom: 0; }
.pw-badge {
display: inline-block;
padding: .18rem .55rem;
border-radius: 10px;
font-size: .72rem;
font-weight: 600;
letter-spacing: .01em;
}
.pw-badge-green { background: #d1fae5; color: #065f46; }
.pw-badge-amber { background: #fef3c7; color: #92400e; }
.pw-badge-red { background: #fee2e2; color: #991b1b; }
.pw-badge-grey { background: #e2e8f0; color: #475569; }
</style>
<table class="pw-compliance-table">
<thead>
<tr>
<th>Service</th>
<th>Carrier</th>
<th>Ordered</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for co in compliance_orders %}
<tr>
<td>
<div style="font-weight:600;color:#1f2937;">{{ co.service_label }}</div>
<div style="font-family:monospace;font-size:.75rem;color:#64748b;">{{ co.order_number }}</div>
</td>
<td>
{% if co.entity_name %}
<div>{{ co.entity_name }}</div>
{% if co.entity_frn %}
<div style="font-size:.75rem;color:#64748b;">FRN {{ co.entity_frn }}</div>
{% endif %}
{% else %}
<span style="color:#94a3b8;"></span>
{% endif %}
</td>
<td style="white-space:nowrap;color:#475569;">
{{ frappe.utils.format_date(co.created_at) if co.created_at else "" }}
</td>
<td>
<span class="pw-badge {{ co.filing_badge.css_class }}">
{{ co.filing_badge.label }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p style="font-size:.8rem;color:#94a3b8;text-align:center;margin-top:1.5rem;">
Need help? <a href="https://performancewest.net/contact" style="color:var(--pw-blue);">Contact support →</a>
</p>
</div>
{% endblock %}

View file

@ -0,0 +1,306 @@
"""
/orders Customer order status portal page.
Shows all CRTC and Formation Sales Orders for the logged-in customer,
with a visual pipeline of workflow states.
Access: portal.performancewest.net/orders
Requires: Customer role (Website User linked to a Customer record)
"""
import os
import frappe
# ── FCC compliance service metadata (shared with the Express API catalog) ──
# The price + name mirror api/src/routes/compliance-orders.ts — we replicate
# the minimum fields needed for portal rendering here to avoid a runtime
# cross-service call on every portal hit.
COMPLIANCE_SERVICE_LABELS = {
"fcc-compliance-checkup": "FCC Carrier Compliance Checkup",
"fcc-499a": "FCC Form 499-A Filing",
"fcc-499a-499q": "FCC Form 499-A + 499-Q Bundle",
"fcc-full-compliance": "FCC Full Compliance Bundle",
"cpni-certification": "CPNI Annual Certification",
"rmd-filing": "RMD Registration / Recertification",
"stir-shaken": "STIR/SHAKEN Implementation Assistance",
"dc-agent": "D.C. Registered Agent (Annual)",
"bdc-filing": "BDC / Form 477 Filing",
}
# Map the per-filing timestamp columns on telecom_entities → the slugs that
# drove them. Used to render "Filed / Overdue / Upcoming" badges on the
# Compliance section.
ENTITY_FILING_FIELDS = {
"rmd-filing": "rmd_last_cert_date",
"cpni-certification": "cpni_last_cert_date",
"fcc-499a": "last_filing_year",
"fcc-499a-499q": "last_filing_year",
"bdc-filing": "bdc_last_filing_date",
"stir-shaken": "stir_shaken_cert_issued_at",
}
# CRTC workflow states in display order with user-facing labels
CRTC_PIPELINE = [
("Received", "Order Received"),
("Awaiting Funds", "Payment Processing"),
("Client Selection", "Setup: Choose Mailbox & Number"),
("Incorporation Filed","BC Incorporation Filed"),
("Pending eSign", "Sign CRTC Letter"),
("CRTC Submitted", "CRTC Registration Submitted"),
("DID Provisioned", "Phone Number Active"),
("Domain Registered", "Domain Registered"),
("Binder Compiled", "Corporate Binder Ready"),
("Delivered", "Delivered"),
]
CRTC_STATE_INDEX = {state: i for i, (state, _) in enumerate(CRTC_PIPELINE)}
def get_context(context):
# Must be logged in as a Customer portal user
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/orders"
raise frappe.Redirect
context.no_cache = 1
context.show_sidebar = True
context.title = "My Orders"
# Find the Customer linked to this portal user
customer_name = frappe.db.get_value(
"Customer", {"portal_user_name": frappe.session.user}, "name"
)
if not customer_name:
# Try matching by email_id
customer_name = frappe.db.get_value(
"Customer", {"email_id": frappe.session.user}, "name"
)
if not customer_name:
context.orders = []
context.message = "No customer account found. Please contact support."
return
context.customer_name = customer_name
# Fetch all Sales Orders for this customer (CRTC + Formation)
raw_orders = frappe.get_all(
"Sales Order",
filters={"customer": customer_name, "docstatus": ["!=", 2]},
fields=[
"name", "custom_external_order_id", "custom_order_type",
"workflow_state", "transaction_date", "grand_total",
"custom_mailbox_address", "custom_payment_gateway",
"status",
],
order_by="transaction_date desc",
limit=50,
)
orders = []
for so in raw_orders:
order_type = so.get("custom_order_type") or "formation"
state = so.get("workflow_state") or ""
ext_id = so.get("custom_external_order_id") or so["name"]
if order_type == "canada_crtc":
pipeline = CRTC_PIPELINE
step_index = CRTC_STATE_INDEX.get(state, 0)
total_steps = len(CRTC_PIPELINE)
type_label = "Canada CRTC Package"
action_url = None
# If in Client Selection state, surface the setup link
if state == "Client Selection":
action_url = f"/portal/setup?order={ext_id}"
action_label = "Complete Setup →"
elif state == "Pending eSign":
action_url = "/portal/manage-services"
action_label = "Sign CRTC Letter →"
else:
action_label = None
else:
pipeline = []
step_index = 0
total_steps = 0
type_label = "Business Formation"
action_url = None
action_label = None
# Build step list for template rendering
steps = []
for i, (s, label) in enumerate(pipeline):
if i < step_index:
status_cls = "completed"
elif i == step_index:
status_cls = "active"
else:
status_cls = "pending"
steps.append({"label": label, "status": status_cls, "state": s})
# Fetch linked invoices
invoices = frappe.get_all(
"Sales Invoice",
filters={
"custom_external_order_id": ext_id,
"docstatus": ["!=", 2],
},
fields=["name", "status", "grand_total", "outstanding_amount"],
limit=5,
)
orders.append({
"name": so["name"],
"ext_id": ext_id,
"order_type": order_type,
"type_label": type_label,
"state": state,
"date": so.get("transaction_date"),
"total": so.get("grand_total") or 0,
"gateway": so.get("custom_payment_gateway") or "",
"address": so.get("custom_mailbox_address") or "",
"steps": steps,
"step_index": step_index,
"total_steps": total_steps,
"pct": int((step_index / max(total_steps - 1, 1)) * 100) if total_steps else 0,
"invoices": invoices,
"action_url": action_url,
"action_label": action_label,
"is_delivered": state == "Delivered",
"is_cancelled": so.get("status") in ("Cancelled", "Closed"),
})
context.orders = orders
context.has_orders = bool(orders)
# ── Compliance section (FCC checkup + remediation filings) ─────────
context.compliance_orders = _fetch_compliance_orders_for_user(frappe.session.user)
context.has_compliance = bool(context.compliance_orders)
# --------------------------------------------------------------------------- #
# Compliance section
# --------------------------------------------------------------------------- #
def _fetch_compliance_orders_for_user(user_email: str) -> list[dict]:
"""Query the Postgres compliance_orders table directly.
The Express API is the authoritative owner of compliance_orders, but
ERPNext is the portal. We read from PG read-only here rather than
force another REST round-trip per portal hit. Connection info comes
from the same ``DATABASE_URL`` the workers use.
"""
database_url = os.environ.get("DATABASE_URL")
if not database_url:
frappe.log_error("DATABASE_URL not set — skipping Compliance section", "orders")
return []
try:
import psycopg2
import psycopg2.extras
except ImportError:
frappe.log_error(
"psycopg2 not installed on the Frappe bench — skipping "
"Compliance section",
"orders",
)
return []
rows: list[dict] = []
try:
with psycopg2.connect(database_url) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT co.order_number, co.service_slug, co.service_name,
co.service_fee_cents, co.payment_status,
co.created_at, co.paid_at, co.recommended_slugs,
co.telecom_entity_id,
te.legal_name AS entity_name,
te.frn AS entity_frn,
te.rmd_last_cert_date,
te.cpni_last_cert_date,
te.bdc_last_filing_date,
te.stir_shaken_cert_issued_at,
te.last_filing_year
FROM compliance_orders co
LEFT JOIN telecom_entities te
ON te.id = co.telecom_entity_id
WHERE co.customer_email = %s
ORDER BY co.created_at DESC
LIMIT 50
""",
(user_email,),
)
raw = cur.fetchall() or []
except Exception as exc:
frappe.log_error(f"Compliance section PG query failed: {exc}", "orders")
return []
for row in raw:
slug = row["service_slug"]
label = COMPLIANCE_SERVICE_LABELS.get(slug, row["service_name"])
# Filing status badge — read from telecom_entities.*_last_*_date
# (populated by the remediation handlers) to tell the customer
# whether the associated filing actually went through.
badge = _filing_badge(slug, row)
rows.append({
"order_number": row["order_number"],
"service_slug": slug,
"service_label": label,
"service_fee": (row["service_fee_cents"] or 0) / 100.0,
"payment_status": row["payment_status"],
"created_at": row["created_at"],
"paid_at": row["paid_at"],
"entity_name": row["entity_name"] or "",
"entity_frn": row["entity_frn"] or "",
"filing_badge": badge,
"recommended_slugs": list(row["recommended_slugs"] or []),
})
return rows
def _filing_badge(slug: str, row: dict) -> dict:
"""Return a {label, css_class} dict describing the filing status."""
if slug == "fcc-compliance-checkup":
# Checkup is diagnostic-only; use the payment/fulfillment posture.
return _payment_badge(row)
field = ENTITY_FILING_FIELDS.get(slug)
if not field:
return _payment_badge(row)
value = row.get(field)
if not value:
if row.get("payment_status") == "paid":
return {"label": "Processing", "css_class": "pw-badge-amber"}
return {"label": "Not filed", "css_class": "pw-badge-grey"}
if field == "last_filing_year":
try:
from datetime import datetime as _dt
if int(value) >= _dt.utcnow().year:
return {"label": "Filed", "css_class": "pw-badge-green"}
except (TypeError, ValueError):
pass
return {"label": "Overdue", "css_class": "pw-badge-red"}
# TIMESTAMPTZ columns — psycopg2 returns datetime instances.
return {"label": "Filed", "css_class": "pw-badge-green"}
def _payment_badge(row: dict) -> dict:
status = (row.get("payment_status") or "").lower()
return {
"paid": {"label": "Delivered", "css_class": "pw-badge-green"},
"pending_payment": {"label": "Awaiting payment", "css_class": "pw-badge-amber"},
"refunded": {"label": "Refunded", "css_class": "pw-badge-grey"},
"cancelled": {"label": "Cancelled", "css_class": "pw-badge-grey"},
}.get(status, {"label": status.title() or "Unknown", "css_class": "pw-badge-grey"})

View file

@ -0,0 +1,40 @@
{% extends "templates/web.html" %}
{% block title %}Redirecting to Checkout…{% endblock %}
{% block page_content %}
<div class="container" style="text-align:center; padding: 4rem 1rem;">
{% if error %}
<div class="alert alert-danger" role="alert">
<h4>Payment Error</h4>
<p>{{ error }}</p>
<a href="/" class="btn btn-secondary">Return Home</a>
</div>
{% else %}
<div id="redirect-notice">
<h3>Redirecting to secure checkout…</h3>
<p>Please wait. You will be redirected to Stripe to complete your payment.</p>
<div class="spinner-border text-primary" role="status" aria-label="Loading">
<span class="visually-hidden">Loading…</span>
</div>
</div>
<script>
(function () {
var url = {{ checkout_url | tojson }};
if (url) {
window.location.href = url;
} else {
document.getElementById("redirect-notice").innerHTML =
'<div class="alert alert-danger">' +
'<p>Could not initialize payment. Please contact support.</p>' +
'<a href="/" class="btn btn-secondary">Return Home</a>' +
'</div>';
}
})();
</script>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,113 @@
"""
PW Stripe Checkout Page
URL: /pw_stripe_checkout?<payment_request_params>
Flow:
1. Receive kwargs from get_payment_url() includes payment_request name, amount, etc.
2. Look up the Payment Request in ERPNext
3. Look up the PW Stripe Settings gateway controller
4. Create a Stripe Checkout Session
5. Store session_id on the Payment Request (for status polling)
6. Redirect to session.url
"""
import frappe
from frappe import _
from frappe.utils import get_url
def get_context(context):
context.no_cache = 1
# ── Parse incoming query params ──────────────────────────────────────────
form_dict = frappe.form_dict
payment_request_name = form_dict.get("payment_request") or form_dict.get("reference_name")
reference_doctype = form_dict.get("reference_doctype", "Sales Invoice")
reference_name = form_dict.get("reference_name", "")
payer_email = form_dict.get("payer_email", "")
amount = form_dict.get("amount", "0")
currency = form_dict.get("currency", "USD")
return_url = form_dict.get("return_url", get_url("/"))
cancel_url_param = form_dict.get("cancel_url", get_url("/"))
try:
# ── Look up the Payment Request ───────────────────────────────────────
if not payment_request_name:
raise ValueError("payment_request parameter is required")
payment_request = frappe.get_doc("Payment Request", payment_request_name)
if payment_request.status in ("Paid", "Cancelled"):
context.error = _("This payment request has already been processed.")
context.checkout_url = ""
return
# ── Determine gateway settings ────────────────────────────────────────
# payment_gateway_account identifies the active card or ACH gateway account
# The controller name is stored in the Payment Gateway Account
gateway_account = frappe.get_doc("Payment Gateway Account", payment_request.payment_gateway_account)
settings_name = gateway_account.gateway_settings # e.g. "Card" or "ACH"
stripe_settings = frappe.get_doc("PW Stripe Settings", settings_name)
if not stripe_settings.enabled:
raise ValueError(f"PW Stripe Settings '{settings_name}' is disabled")
# ── Build success/cancel URLs ─────────────────────────────────────────
# Success URL must include {CHECKOUT_SESSION_ID} for Stripe to substitute
domain = frappe.local.conf.get("host_name") or frappe.utils.get_host_name()
site_url = f"https://{domain}" if not domain.startswith("http") else domain
# The website's order success page (Astro site on same domain or separate)
pw_domain = frappe.db.get_single_value("System Settings", "website_baseurl") or site_url
success_url = (
f"{pw_domain}/order/success"
f"?session_id={{CHECKOUT_SESSION_ID}}"
f"&order_id={frappe.utils.escape_html(reference_name)}"
f"&order_type={frappe.utils.escape_html(form_dict.get('order_type', ''))}"
)
cancel_url = (
f"{pw_domain}/order/cancel"
f"?order_id={frappe.utils.escape_html(reference_name)}"
)
# ── Convert amount to cents ───────────────────────────────────────────
amount_cents = int(float(amount) * 100)
# ── Create Stripe Checkout Session ────────────────────────────────────
description = f"Payment for {reference_doctype}: {reference_name}"
session = stripe_settings.create_checkout_session(
amount_cents=amount_cents,
currency=currency,
payment_request_name=payment_request_name,
reference_doctype=reference_doctype,
reference_name=reference_name,
customer_email=payer_email,
success_url=success_url,
cancel_url=cancel_url,
description=description,
)
# ── Store Stripe session ID on the Payment Request ────────────────────
frappe.db.set_value(
"Payment Request",
payment_request_name,
{
"custom_stripe_session_id": session.id,
"status": "Initiated",
},
)
frappe.db.commit()
# ── Redirect to Stripe Checkout ───────────────────────────────────────
context.checkout_url = session.url
context.error = None
except Exception as e:
frappe.log_error(
f"[pw_stripe_checkout] Error for payment_request={payment_request_name}: {e}",
"PW Stripe Checkout Error",
)
context.error = _("Could not initialize payment. Please contact support.")
context.checkout_url = ""

View file

@ -0,0 +1,124 @@
{% extends "templates/web.html" %}
{% block page_content %}
<style>
:root { --pw-navy: #1e3a5f; --pw-blue: #2d4e78; --pw-green: #059669; }
.pw-sp-card {
max-width: 440px;
margin: 3rem auto;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 2rem 2rem 2.25rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
}
.pw-sp-card h1 { margin: 0 0 .5rem; color: var(--pw-navy); font-size: 1.4rem; }
.pw-sp-card p.pw-intro { margin: 0 0 1.25rem; color: #475569; font-size: .95rem; }
.pw-sp-card label { display: block; font-weight: 600; margin: 1rem 0 .4rem; color: #1f2937; }
.pw-sp-card input[type="password"] {
width: 100%; padding: .6rem .75rem; border: 1px solid #cbd5e1;
border-radius: 6px; font-size: 1rem;
}
.pw-sp-card button {
margin-top: 1.5rem; width: 100%;
background: var(--pw-green); color: #fff; border: 0;
padding: .75rem 1rem; border-radius: 6px;
font-size: 1rem; font-weight: 600; cursor: pointer;
}
.pw-sp-card button:disabled { background: #9ca3af; cursor: not-allowed; }
.pw-sp-err { color: #b91c1c; margin-top: 1rem; font-size: .9rem; }
.pw-sp-ok { color: #065f46; margin-top: 1rem; font-size: .9rem; }
.pw-sp-help { color: #64748b; font-size: .8rem; margin-top: .4rem; }
</style>
<div class="pw-sp-card">
{% if error %}
<h1>Link invalid</h1>
<p class="pw-intro">{{ error }}</p>
{% else %}
<h1>Set your password</h1>
<p class="pw-intro">
Hi — set a password for <strong>{{ email }}</strong> to view your compliance
deliverables and place follow-up orders.
{% if order_number %}
<br><span class="pw-order-id">Order: {{ order_number }}</span>
{% endif %}
</p>
<form id="pw-set-password-form" autocomplete="off">
<input type="hidden" name="token" value="{{ token }}">
<label for="pw-pass">New password</label>
<input id="pw-pass" name="password" type="password" minlength="8" required>
<div class="pw-sp-help">At least 8 characters.</div>
<label for="pw-pass2">Confirm password</label>
<input id="pw-pass2" name="password2" type="password" minlength="8" required>
<button type="submit" id="pw-sp-submit">Set password &amp; continue</button>
<div id="pw-sp-msg"></div>
</form>
{% endif %}
</div>
<script>
(function () {
const form = document.getElementById("pw-set-password-form");
if (!form) return;
const msg = document.getElementById("pw-sp-msg");
const btn = document.getElementById("pw-sp-submit");
form.addEventListener("submit", async function (e) {
e.preventDefault();
msg.textContent = "";
msg.className = "";
const pass = form.password.value;
const pass2 = form.password2.value;
if (pass !== pass2) {
msg.className = "pw-sp-err";
msg.textContent = "Passwords do not match.";
return;
}
btn.disabled = true;
btn.textContent = "Setting password…";
try {
const resp = await fetch(
"/api/method/performancewest_erpnext.www.set-password.submit",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
token: form.token.value,
password: pass,
}).toString(),
credentials: "same-origin",
}
);
const data = await resp.json();
if (resp.ok && data.message && data.message.success) {
msg.className = "pw-sp-ok";
msg.textContent = "Password set — redirecting…";
window.location.href = data.message.redirect || "/orders";
} else {
const err =
(data && (data._server_messages || data.exc || data.message && data.message.error)) ||
"Could not set password.";
msg.className = "pw-sp-err";
msg.textContent = typeof err === "string" ? err : "Could not set password.";
btn.disabled = false;
btn.textContent = "Set password & continue";
}
} catch (err) {
msg.className = "pw-sp-err";
msg.textContent = "Network error. Please try again.";
btn.disabled = false;
btn.textContent = "Set password & continue";
}
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,125 @@
"""
/set-password JWT-gated password onboarding for new portal customers.
Invoked from the magic link in the compliance delivery email (see
``scripts/workers/delivery_worker.py``). The token is HS256-signed with
``CUSTOMER_JWT_SECRET`` (the same secret used by the CRTC eSign flow)
and carries:
{
"email": "...",
"order_number": "CO-XXXXXXXX",
"purpose": "set_password",
"iat": ..., "exp": ...
}
Access: portal.performancewest.net/set-password?token=...
On valid POST: sets the ERPNext User password, logs the user in, and
redirects to /orders.
"""
from __future__ import annotations
import os
from datetime import datetime
import frappe
from frappe import _
from frappe.utils.password import update_password
JWT_SECRET_ENV = "CUSTOMER_JWT_SECRET"
JWT_FALLBACK_SITE_CONFIG_KEY = "customer_jwt_secret"
def _get_secret() -> str:
"""Resolve the JWT secret from env, then site_config."""
secret = os.environ.get(JWT_SECRET_ENV) or frappe.conf.get(
JWT_FALLBACK_SITE_CONFIG_KEY
)
if not secret:
# Same default as the worker (dev only — logged-in deploys must
# set CUSTOMER_JWT_SECRET in the environment).
secret = "changeme_long_random_string"
return secret
def _verify_token(token: str) -> dict | None:
"""Return the decoded payload if valid and purpose matches."""
try:
import jwt as _jwt
except ImportError:
frappe.log_error(
"PyJWT not installed on the Frappe bench — cannot verify "
"set-password token",
"set-password",
)
return None
try:
payload = _jwt.decode(token, _get_secret(), algorithms=["HS256"])
except Exception as exc:
frappe.log_error(f"set-password: invalid token: {exc}", "set-password")
return None
if payload.get("purpose") != "set_password":
return None
if not payload.get("email"):
return None
return payload
def get_context(context):
context.no_cache = 1
context.show_sidebar = False
context.title = "Set your password"
token = (frappe.form_dict.get("token") or "").strip()
payload = _verify_token(token) if token else None
if not payload:
context.error = _(
"This link is invalid or has expired. Please check the most "
"recent delivery email, or contact support."
)
context.email = ""
context.token = ""
return
context.email = payload["email"]
context.token = token
context.order_number = payload.get("order_number", "")
context.expires_at = datetime.utcfromtimestamp(payload["exp"]).isoformat()
# ─── Whitelisted method: set the password ─────────────────────────────────
@frappe.whitelist(allow_guest=True)
def submit(token: str, password: str) -> dict:
"""Validate the token, set the user's password, and log them in."""
if not token or not password:
frappe.throw(_("Token and password are required."))
if len(password) < 8:
frappe.throw(_("Password must be at least 8 characters."))
payload = _verify_token(token)
if not payload:
frappe.throw(_("Invalid or expired link."))
email = payload["email"].lower().strip()
# The Website User must already exist — it was created by the
# Express API's ensureWebsiteUser call during checkout. Guard for
# the edge case where the user was deleted between order and email.
if not frappe.db.exists("User", email):
frappe.throw(_("No portal account found for this email. Contact support."))
update_password(email, password)
# Log the user in so they land on /orders authenticated.
frappe.local.login_manager.login_as(email)
return {"success": True, "redirect": "/orders"}

View file

@ -0,0 +1,21 @@
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "performancewest_erpnext"
version = "1.0.0"
description = "Custom payment gateways, surcharge hooks, and identity verification for Performance West"
license = { text = "MIT" }
authors = [
{ name = "Performance West Inc.", email = "support@performancewest.net" }
]
requires-python = ">=3.10"
dependencies = [
"frappe>=15.0.0,<16",
"erpnext>=15.0.0,<16",
"payments",
]
[project.urls]
Homepage = "https://performancewest.net"

View file

@ -0,0 +1,3 @@
frappe>=15.0.0,<16
erpnext>=15.0.0,<16
payments

View file

@ -0,0 +1,16 @@
from setuptools import setup, find_packages
with open("requirements.txt") as f:
install_requires = f.read().strip().split("\n")
setup(
name="performancewest_erpnext",
version="1.0.0",
description="Custom payment gateways, surcharge hooks, and identity verification for Performance West",
author="Performance West Inc.",
author_email="support@performancewest.net",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=install_requires,
)