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>
300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""
|
|
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}
|