new-site/performancewest_erpnext/performancewest_erpnext/api.py
justin f8cd37ac8c 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>
2026-04-27 06:54:22 -05:00

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}