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>
207 lines
6.8 KiB
Python
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}"
|
|
)
|