From fc9e9ddfb02396304c8a961c1242a5778e5c2426 Mon Sep 17 00:00:00 2001 From: def Date: Sun, 15 Mar 2026 22:54:23 +0000 Subject: [PATCH] Add crypto processing watcher and portal auto-refresh --- backend/app.py | 282 ++++++++++++++++++++++++--- templates/portal_dashboard.html | 9 + templates/portal_invoice_detail.html | 22 ++- 3 files changed, 280 insertions(+), 33 deletions(-) diff --git a/backend/app.py b/backend/app.py index 69e176c..1ed9b91 100644 --- a/backend/app.py +++ b/backend/app.py @@ -24,6 +24,8 @@ import math import zipfile import smtplib import secrets +import threading +import time from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader @@ -58,6 +60,11 @@ RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-r RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "900")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + def load_version(): @@ -388,6 +395,222 @@ def verify_wallet_transaction(option, tx_hash): return seen_result + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] confirmed via {rpc_url}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmations, 1), + confirmation_required = COALESCE(confirmation_required, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + global CRYPTO_WATCHER_STARTED + if CRYPTO_WATCHER_STARTED: + return + + t = threading.Thread( + target=crypto_payment_watcher_loop, + name="crypto-payment-watcher", + daemon=True, + ) + t.start() + CRYPTO_WATCHER_STARTED = True + def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1"))) @@ -4622,22 +4845,27 @@ def portal_submit_crypto_tx(invoice_id): ensure_invoice_quote_columns() - payload = request.get_json(silent=True) or {} + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + payment_id = str(payload.get("payment_id") or "").strip() - asset_symbol = str(payload.get("asset") or "").strip().upper() + asset = str(payload.get("asset") or "").strip().upper() tx_hash = str(payload.get("tx_hash") or "").strip() if not payment_id.isdigit(): return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 - if not re.fullmatch(r"0x[a-fA-F0-9]{64}", tx_hash): - return jsonify({"ok": False, "error": "invalid_tx_hash"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" - SELECT id, client_id, invoice_number, status, total_amount, amount_paid, - quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + SELECT id, client_id, invoice_number, oracle_snapshot FROM invoices WHERE id = %s AND client_id = %s LIMIT 1 @@ -4655,50 +4883,50 @@ def portal_submit_crypto_tx(invoice_id): except Exception: invoice["oracle_quote"] = None - options = get_invoice_crypto_options(invoice) - selected_option = next((o for o in options if o["symbol"] == asset_symbol), None) + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + if not selected_option: conn.close() - return jsonify({"ok": False, "error": "asset_not_allowed"}), 400 - if not selected_option.get("wallet_capable"): - conn.close() - return jsonify({"ok": False, "error": "asset_not_wallet_capable"}), 400 + return jsonify({"ok": False, "error": "invalid_asset"}), 400 cursor.execute(""" - SELECT id, invoice_id, client_id, payment_currency, payment_amount, wallet_address, reference, - payment_status, created_at, received_at, txid, notes + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes FROM payments WHERE id = %s AND invoice_id = %s AND client_id = %s LIMIT 1 - """, (payment_id, invoice_id, client["id"])) + """, (int(payment_id), invoice_id, client["id"])) payment = cursor.fetchone() if not payment: conn.close() return jsonify({"ok": False, "error": "payment_not_found"}), 404 - if str(payment.get("payment_currency") or "").upper() != str(selected_option.get("payment_currency") or "").upper(): + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: conn.close() return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 - try: - verify_wallet_transaction(selected_option, tx_hash) - except Exception as err: + if str(payment.get("payment_status") or "").lower() != "pending": conn.close() - return jsonify({"ok": False, "error": "tx_verification_failed", "detail": str(err)}), 400 + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET txid = %s, - received_at = UTC_TIMESTAMP(), - notes = CONCAT(COALESCE(notes, ''), %s) + payment_status = 'pending', + notes = %s WHERE id = %s """, ( tx_hash, - f" | tx_submitted | rpc_seen | chain:{selected_option['chain']} | asset:{selected_option['symbol']}", + new_notes, payment["id"] )) conn.commit() @@ -4706,14 +4934,10 @@ def portal_submit_crypto_tx(invoice_id): return jsonify({ "ok": True, - "payment_id": payment["id"], - "tx_hash": tx_hash, - "chain": selected_option["chain"], - "asset": selected_option["symbol"], - "state": "submitted", - "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={payment['id']}" + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" }) + @app.route("/portal/invoice//pay-square", methods=["GET"]) def portal_invoice_pay_square(invoice_id): client = _portal_current_client() diff --git a/templates/portal_dashboard.html b/templates/portal_dashboard.html index c0867a8..a43083e 100644 --- a/templates/portal_dashboard.html +++ b/templates/portal_dashboard.html @@ -148,6 +148,15 @@ + + + {% include "footer.html" %} diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index 3d885a8..a190c45 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -155,7 +155,9 @@

Status

{% set s = (invoice.status or "")|lower %} - {% if s == "paid" %}{{ invoice.status }} + {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} + processing + {% elif s == "paid" %}{{ invoice.status }} {% elif s == "pending" %}{{ invoice.status }} {% elif s == "overdue" %}{{ invoice.status }} {% else %}{{ invoice.status }}{% endif %} @@ -219,7 +221,7 @@ {% if pending_crypto_payment and pending_crypto_payment.txid %}
--:--
-
Waiting for network confirmation
+
Waiting for network confirmation / processing
{% elif pending_crypto_payment %}
@@ -299,7 +301,7 @@ {% elif pending_crypto_payment.txid %}
Transaction Hash:
{{ pending_crypto_payment.txid }} -
Transaction submitted and detected on RPC. Waiting for network confirmation.
+
Transaction submitted and detected on RPC. Waiting for network confirmation / processing.
{% elif pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update
{% endif %} @@ -308,7 +310,7 @@ {% if pending_crypto_payment and pending_crypto_payment.txid %}
--:--
-
Waiting for network confirmation
+
Waiting for network confirmation / processing
{% else %}
@@ -620,6 +622,18 @@ Reference: ${invoiceRef}`; })(); + + + {% include "footer.html" %}