diff --git a/backend/app.py b/backend/app.py index 1ed9b91..bb22c37 100644 --- a/backend/app.py +++ b/backend/app.py @@ -60,7 +60,7 @@ 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_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) CRYPTO_WATCHER_STARTED = False @@ -318,6 +318,285 @@ def rpc_call_any(rpc_urls, method, params): raise last_error raise RuntimeError("No RPC URLs configured") + +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 _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +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 reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + def _to_base_units(amount_text, decimals): amount_dec = Decimal(str(amount_text)) scale = Decimal(10) ** int(decimals) @@ -4304,6 +4583,42 @@ def portal_invoice_detail(invoice_id): None ) + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + pdf_url = f"/invoices/pdf/{invoice_id}" conn.close() diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index a190c45..b4fc582 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -221,7 +221,7 @@ {% if pending_crypto_payment and pending_crypto_payment.txid %}
{{ pending_crypto_payment.txid }}
-