""" 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}" )