new-site/frappe_crypto/frappe_crypto/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

207 lines
6.8 KiB
Python

"""
API endpoints for frappe_crypto.
- crypto_webhook: receives SHKeeper payment callbacks (allow_guest, CSRF-exempt)
- get_payment_status: polled by the checkout page to check if payment is confirmed
"""
import json
import frappe
from frappe import _
@frappe.whitelist(allow_guest=True)
def crypto_webhook():
"""
Receive SHKeeper payment callbacks.
SHKeeper POSTs when an invoice receives a transaction.
Body includes: external_id (Payment Request name), paid (bool), status (1/2/3),
balance_fiat, balance_crypto, crypto, transactions[].
Auth: verify X-Shkeeper-Api-Key header matches our stored api_key.
Must return HTTP 202 to acknowledge. Any other response = SHKeeper retries every 60s.
Status codes from SHKeeper:
1 = pending (partial payment received)
2 = paid (exact amount received)
3 = overpaid (more than expected received)
"""
try:
data = json.loads(frappe.request.data)
except (json.JSONDecodeError, TypeError):
frappe.local.response.http_status_code = 202
return {"status": "accepted", "message": "Invalid JSON body"}
external_id = data.get("external_id", "")
is_paid = data.get("paid", False)
status_code = data.get("status", 0)
crypto = data.get("crypto", "")
balance_fiat = data.get("balance_fiat", "0")
balance_crypto = data.get("balance_crypto", "0")
transactions = data.get("transactions", [])
frappe.logger("frappe_crypto").info(
f"Webhook received: external_id={external_id}, paid={is_paid}, "
f"status={status_code}, crypto={crypto}, balance_fiat={balance_fiat}"
)
# Verify API key
incoming_key = frappe.request.headers.get("X-Shkeeper-Api-Key", "")
if not _verify_webhook_key(incoming_key):
frappe.logger("frappe_crypto").warning(
f"Webhook auth failed for external_id={external_id}"
)
frappe.local.response.http_status_code = 202
return {"status": "accepted", "message": "Auth failed"}
# Validate that the Payment Request exists
if not external_id or not frappe.db.exists("Payment Request", external_id):
frappe.logger("frappe_crypto").warning(
f"Payment Request not found: {external_id}"
)
frappe.local.response.http_status_code = 202
return {"status": "accepted", "message": "Payment Request not found"}
# Process payment if paid or overpaid
if is_paid or status_code in (2, 3):
_process_payment(
payment_request_name=external_id,
amount_fiat=balance_fiat,
amount_crypto=balance_crypto,
crypto=crypto,
transactions=transactions,
status_code=status_code,
)
frappe.local.response.http_status_code = 202
return {"status": "accepted"}
@frappe.whitelist(allow_guest=True)
def get_payment_status(payment_request_name: str = ""):
"""
Check the status of a Payment Request.
Polled by the crypto_checkout page every 5 seconds.
"""
if not payment_request_name:
return {"status": "error", "message": "Missing payment_request_name"}
if not frappe.db.exists("Payment Request", payment_request_name):
return {"status": "error", "message": "Payment Request not found"}
pr = frappe.get_doc("Payment Request", payment_request_name)
return {
"status": "ok",
"payment_status": pr.status,
"paid": pr.status == "Paid",
"amount": pr.grand_total,
"currency": pr.currency,
}
def _verify_webhook_key(incoming_key: str) -> bool:
"""
Verify the incoming X-Shkeeper-Api-Key header against stored API keys.
Checks all Crypto Payment Settings documents for a matching key.
"""
if not incoming_key:
return False
settings_list = frappe.get_all(
"Crypto Payment Settings",
filters={"enabled": 1},
pluck="name",
)
for settings_name in settings_list:
doc = frappe.get_doc("Crypto Payment Settings", settings_name)
stored_key = doc.get_password(fieldname="api_key", raise_exception=False) or ""
if stored_key and stored_key == incoming_key:
return True
return False
def _process_payment(
payment_request_name: str,
amount_fiat: str,
amount_crypto: str,
crypto: str,
transactions: list,
status_code: int,
):
"""
Create a Payment Entry and mark the Payment Request as Paid.
Idempotent: skips if PR is already Paid.
"""
pr = frappe.get_doc("Payment Request", payment_request_name)
# Idempotency check — don't create duplicate Payment Entry
if pr.status == "Paid":
frappe.logger("frappe_crypto").info(
f"Payment Request {payment_request_name} already Paid, skipping"
)
return
try:
amount = float(amount_fiat)
except (ValueError, TypeError):
amount = pr.grand_total
# Build remarks with crypto transaction details
remarks_parts = [
f"Crypto payment via SHKeeper",
f"Crypto: {crypto}",
f"Amount (crypto): {amount_crypto}",
f"Amount (fiat): ${amount_fiat} USD",
f"SHKeeper status: {status_code}",
]
if transactions:
tx_hashes = [t.get("txid", t.get("tx_hash", "unknown")) for t in transactions[:5]]
remarks_parts.append(f"TX hashes: {', '.join(tx_hashes)}")
remarks = " | ".join(remarks_parts)
try:
# Use the Payment Request's built-in method to create Payment Entry
# This handles all the GL entry creation and reference linking
pr.flags.ignore_permissions = True
pr.run_method("set_as_paid")
# Update the Payment Entry with crypto-specific remarks
payment_entries = frappe.get_all(
"Payment Entry",
filters={
"reference_no": payment_request_name,
"docstatus": ["in", [0, 1]],
},
pluck="name",
order_by="creation desc",
limit=1,
)
if payment_entries:
pe = frappe.get_doc("Payment Entry", payment_entries[0])
pe.remarks = remarks
pe.reference_no = payment_request_name
pe.save(ignore_permissions=True)
frappe.db.commit()
frappe.logger("frappe_crypto").info(
f"Payment processed for {payment_request_name}: "
f"${amount_fiat} USD in {crypto}"
)
except Exception as e:
frappe.log_error(
title=f"Crypto Payment Error: {payment_request_name}",
message=f"Error processing crypto payment:\n{str(e)}\n\n"
f"Data: crypto={crypto}, amount_fiat={amount_fiat}, "
f"amount_crypto={amount_crypto}, status={status_code}",
)
frappe.logger("frappe_crypto").error(
f"Error processing payment for {payment_request_name}: {e}"
)