Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
19
performancewest_erpnext/README.md
Normal file
19
performancewest_erpnext/README.md
Normal 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`
|
||||
|
|
@ -0,0 +1 @@
|
|||
__version__ = "1.0.0"
|
||||
300
performancewest_erpnext/performancewest_erpnext/api.py
Normal file
300
performancewest_erpnext/performancewest_erpnext/api.py
Normal 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}
|
||||
|
|
@ -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 & 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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>10–15 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>2–4 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
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 = ""
|
||||
46
performancewest_erpnext/performancewest_erpnext/hooks.py
Normal file
46
performancewest_erpnext/performancewest_erpnext/hooks.py
Normal 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",
|
||||
]
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Payment Gateways
|
||||
Compliance
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 & 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 <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 & 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 %}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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't tagged show up
|
||||
as <em>unknown</em> — tag them so your study is complete.
|
||||
</p>
|
||||
|
||||
{% if not profile %}
|
||||
<div class="pw-cdr-card">
|
||||
<p>No CDR profile yet — <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 %}
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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's CDR
|
||||
ingestion and we'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 — <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 — most commercial switches do). Turn it on if you
|
||||
run a flat-rate line service or your switch doesn'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 & 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'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 %}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 & 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 & 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 > 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 %}
|
||||
|
|
@ -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
|
||||
300
performancewest_erpnext/performancewest_erpnext/www/orders.html
Normal file
300
performancewest_erpnext/performancewest_erpnext/www/orders.html
Normal 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 %} · {{ 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 %}
|
||||
306
performancewest_erpnext/performancewest_erpnext/www/orders.py
Normal file
306
performancewest_erpnext/performancewest_erpnext/www/orders.py
Normal 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"})
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 = ""
|
||||
|
|
@ -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 & 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 %}
|
||||
|
|
@ -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"}
|
||||
21
performancewest_erpnext/pyproject.toml
Normal file
21
performancewest_erpnext/pyproject.toml
Normal 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"
|
||||
3
performancewest_erpnext/requirements.txt
Normal file
3
performancewest_erpnext/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
frappe>=15.0.0,<16
|
||||
erpnext>=15.0.0,<16
|
||||
payments
|
||||
16
performancewest_erpnext/setup.py
Normal file
16
performancewest_erpnext/setup.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue