diff --git a/backend/app.py b/backend/app.py index a8ff86c..ab291bd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -20,6 +20,7 @@ import urllib.error import urllib.parse import uuid import re +import math import zipfile import smtplib import secrets @@ -49,6 +50,8 @@ SQUARE_API_VERSION = "2026-01-22" SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://cloudflare-eth.com") +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arb1.arbitrum.io/rpc") @@ -191,6 +194,11 @@ def get_invoice_crypto_options(invoice): "label": "USDC (Arbitrum)", "payment_currency": "USDC", "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", }, "ETH": { "symbol": "ETH", @@ -198,6 +206,11 @@ def get_invoice_crypto_options(invoice): "label": "ETH (Ethereum)", "payment_currency": "ETH", "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, }, "ETHO": { "symbol": "ETHO", @@ -205,6 +218,11 @@ def get_invoice_crypto_options(invoice): "label": "ETHO (Etho)", "payment_currency": "ETHO", "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": False, + "asset_type": "native", + "chain_id": None, + "decimals": 18, + "token_contract": None, }, "ETI": { "symbol": "ETI", @@ -212,6 +230,11 @@ def get_invoice_crypto_options(invoice): "label": "ETI (Etica)", "payment_currency": "ETI", "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": False, + "asset_type": "token", + "chain_id": None, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", }, } @@ -235,6 +258,101 @@ def get_invoice_crypto_options(invoice): options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) return options +def get_rpc_url_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return RPC_ETHEREUM_URL + if chain == "arbitrum": + return RPC_ARBITRUM_URL + return None + +def rpc_call(rpc_url, method, params): + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return (data or {}).get("result") + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_url = get_rpc_url_for_chain(option.get("chain")) + if not rpc_url: + raise RuntimeError("No RPC configured for chain") + + tx = rpc_call(rpc_url, "eth_getTransactionByHash", [tx_hash]) + if not tx: + raise RuntimeError("Transaction hash not found on RPC") + + 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 = int(tx.get("value") or "0x0", 16) + 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") + else: + 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 { + "rpc_url": rpc_url, + "tx": tx, + } + def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1"))) @@ -3880,7 +3998,7 @@ def portal_invoice_detail(invoice_id): if payment_id.isdigit(): cursor.execute(""" SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, - reference, wallet_address, payment_status, created_at, notes + reference, wallet_address, payment_status, created_at, received_at, txid, notes FROM payments WHERE id = %s AND invoice_id = %s @@ -3906,6 +4024,22 @@ def portal_invoice_detail(invoice_id): pending_crypto_payment["lock_expires_at_iso"] = "" pending_crypto_payment["lock_expired"] = True + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + if not selected_crypto_option: selected_crypto_option = next( (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), @@ -4445,6 +4579,106 @@ def portal_invoice_pay_crypto(invoice_id): return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + payload = request.get_json(silent=True) or {} + payment_id = str(payload.get("payment_id") or "").strip() + asset_symbol = 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 + + 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 + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + 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) + 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 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, wallet_address, reference, + payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (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(): + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + try: + verify_wallet_transaction(selected_option, tx_hash) + except Exception as err: + conn.close() + return jsonify({"ok": False, "error": "tx_verification_failed", "detail": str(err)}), 400 + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + received_at = UTC_TIMESTAMP(), + notes = CONCAT(COALESCE(notes, ''), %s) + WHERE id = %s + """, ( + tx_hash, + f" | tx_submitted | rpc_seen | chain:{selected_option['chain']} | asset:{selected_option['symbol']}", + payment["id"] + )) + conn.commit() + conn.close() + + 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']}" + }) + @app.route("/portal/invoice//pay-square", methods=["GET"]) def portal_invoice_pay_square(invoice_id): client = _portal_current_client() diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index 25fe781..dd4e22a 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -7,252 +7,59 @@ @@ -273,9 +80,7 @@ {% if (invoice.status or "")|lower == "paid" %} -
- ✓ This invoice has been paid. Thank you! -
+
✓ This invoice has been paid. Thank you!
{% endif %} {% if crypto_error %} @@ -283,63 +88,29 @@ {% endif %}
-
-

Invoice

-
{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
-
+

Invoice

{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Status

{% set s = (invoice.status or "")|lower %} - {% if s == "paid" %} - {{ invoice.status }} - {% elif s == "pending" %} - {{ invoice.status }} - {% elif s == "overdue" %} - {{ invoice.status }} - {% else %} - {{ invoice.status }} - {% endif %} -
-
-

Created

-
{{ invoice.created_at }}
-
-
-

Total

-
{{ invoice.total_amount }}
-
-
-

Paid

-
{{ invoice.amount_paid }}
-
-
-

Outstanding

-
{{ invoice.outstanding }}
+ {% if s == "paid" %}{{ invoice.status }} + {% elif s == "pending" %}{{ invoice.status }} + {% elif s == "overdue" %}{{ invoice.status }} + {% else %}{{ invoice.status }}{% endif %}
+

Created

{{ invoice.created_at }}
+

Total

{{ invoice.total_amount }}
+

Paid

{{ invoice.amount_paid }}
+

Outstanding

{{ invoice.outstanding }}

Invoice Items

- - - - - - - - + {% for item in items %} - - - - - - + {% else %} - - - + {% endfor %}
DescriptionQtyUnit PriceLine Total
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.
No invoice line items found.
@@ -358,17 +129,12 @@
-

Interac e-Transfer
- Send payment to:
- payment@outsidethebox.top
- Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

+

Interac e-Transfer
Send payment to:
payment@outsidethebox.top
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

@@ -387,7 +153,12 @@ {% endif %}
- {% if pending_crypto_payment %} + {% if pending_crypto_payment and pending_crypto_payment.txid %} +
+
--:--
+
Waiting for network confirmation
+
+ {% elif pending_crypto_payment %}
--:--
This price is locked for 2 minutes
@@ -401,7 +172,7 @@
{% if pending_crypto_payment and selected_crypto_option %} -
+

{{ selected_crypto_option.label }} Payment Instructions

@@ -410,55 +181,68 @@ {{ pending_crypto_payment.wallet_address }}
Reference / Invoice:
{{ pending_crypto_payment.reference }} -
- {% if pending_crypto_payment.lock_expired %} - price has expired - please refresh your quote to update - {% else %} - Your selected crypto quote has been accepted and placed into processing. - {% endif %} + + {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} +
+ + +
+
+ This will open your wallet, prepare the exact transaction, and submit the tx hash back to the portal automatically.
+ {% elif pending_crypto_payment.txid %} +
Transaction Hash:
+ {{ pending_crypto_payment.txid }} +
Transaction submitted and detected on RPC. Waiting for network confirmation.
+ {% elif pending_crypto_payment.lock_expired %} +
price has expired - please refresh your quote to update
+ {% endif %}
+ + {% if pending_crypto_payment and pending_crypto_payment.txid %} +
+
--:--
+
Waiting for network confirmation
+
+ {% else %}
--:--
This price is locked for 2 minutes
+ {% endif %}
{% else %}
- - - - - - - - - + {% for q in crypto_options %} - - + + {% endfor %} @@ -474,9 +258,7 @@ {% endif %} {% if pdf_url %} - + {% endif %} @@ -511,7 +293,7 @@ }); } - function bindCountdown(valueId, labelId, expireIso, expiredMessage) { + function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { const valueEl = document.getElementById(valueId); const labelEl = document.getElementById(labelId); if (!valueEl || !expireIso) return; @@ -528,14 +310,11 @@ labelEl.textContent = expiredMessage; labelEl.classList.add("snapshot-timer-expired"); } - const cryptoForm = document.getElementById("cryptoPickForm"); - if (cryptoForm) { - cryptoForm.querySelectorAll("button").forEach(btn => btn.disabled = true); + if (disableSelector) { + document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); } const lockBox = document.getElementById("lockBox"); - if (lockBox) { - lockBox.classList.add("expired"); - } + if (lockBox) lockBox.classList.add("expired"); return; } @@ -550,32 +329,116 @@ const quoteTimer = document.getElementById("quoteTimerValue"); if (quoteTimer && quoteTimer.dataset.expiry) { - bindCountdown( - "quoteTimerValue", - "quoteTimerLabel", - quoteTimer.dataset.expiry, - "price has expired - please refresh your view to update" - ); + bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); } const lockTimer = document.getElementById("lockTimerValue"); if (lockTimer && lockTimer.dataset.expiry) { - bindCountdown( - "lockTimerValue", - "lockTimerLabel", - lockTimer.dataset.expiry, - "price has expired - please refresh your quote to update" - ); + bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); } const lockTimerSide = document.getElementById("lockTimerSideValue"); if (lockTimerSide && lockTimerSide.dataset.expiry) { - bindCountdown( - "lockTimerSideValue", - "lockTimerSideLabel", - lockTimerSide.dataset.expiry, - "price has expired - please refresh your quote to update" - ); + bindCountdown("lockTimerSideValue", "lockTimerSideLabel", lockTimerSide.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); + } + + const processingTimer = document.getElementById("processingTimerValue"); + if (processingTimer && processingTimer.dataset.expiry) { + bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); + } + + const processingTimerSide = document.getElementById("processingTimerSideValue"); + if (processingTimerSide && processingTimerSide.dataset.expiry) { + bindCountdown("processingTimerSideValue", "processingTimerSideLabel", processingTimerSide.dataset.expiry, "price has expired - please refresh your quote to update", null); + } + + function toHexBigIntFromDecimal(amountText, decimals) { + const text = String(amountText || "0"); + const parts = text.split("."); + const whole = parts[0] || "0"; + const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); + const combined = (whole + frac).replace(/^0+/, "") || "0"; + return "0x" + BigInt(combined).toString(16); + } + + function erc20TransferData(to, amountText, decimals) { + const method = "a9059cbb"; + const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); + const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); + return "0x" + method + addr + amtHex; + } + + async function switchChain(chainId) { + const hexChainId = "0x" + Number(chainId).toString(16); + await window.ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: hexChainId }] + }); + } + + const walletButton = document.getElementById("walletPayButton"); + if (walletButton) { + walletButton.addEventListener("click", async function() { + const statusEl = document.getElementById("walletStatusText"); + const originalText = walletButton.textContent; + walletButton.disabled = true; + if (statusEl) statusEl.textContent = "Opening wallet…"; + + try { + if (!window.ethereum) { + throw new Error("No wallet detected. Open this page in MetaMask or Rabby."); + } + + const invoiceId = walletButton.dataset.invoiceId; + const paymentId = walletButton.dataset.paymentId; + const asset = walletButton.dataset.asset; + const chainId = walletButton.dataset.chainId; + const assetType = walletButton.dataset.assetType; + const to = walletButton.dataset.to; + const amount = walletButton.dataset.amount; + const decimals = Number(walletButton.dataset.decimals || "18"); + const tokenContract = walletButton.dataset.tokenContract || ""; + + await window.ethereum.request({ method: "eth_requestAccounts" }); + await switchChain(chainId); + + let txHash; + if (assetType === "native") { + txHash = await window.ethereum.request({ + method: "eth_sendTransaction", + params: [{ to: to, value: toHexBigIntFromDecimal(amount, decimals) }] + }); + } else { + txHash = await window.ethereum.request({ + method: "eth_sendTransaction", + params: [{ to: tokenContract, data: erc20TransferData(to, amount, decimals) }] + }); + } + + if (statusEl) statusEl.textContent = "Submitting transaction hash to portal…"; + + const resp = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + payment_id: paymentId, + asset: asset, + tx_hash: txHash + }) + }); + + const data = await resp.json(); + if (!resp.ok || !data.ok) { + throw new Error((data && (data.detail || data.error)) || "Portal rejected tx hash"); + } + + window.location.href = data.redirect_url; + } catch (err) { + if (statusEl) statusEl.textContent = String(err.message || err); + walletButton.disabled = false; + walletButton.textContent = originalText; + } + }); } })();
AssetQuoted AmountCAD PriceStatusAction
AssetQuoted AmountCAD PriceStatusAction
{{ q.label }} - {% if q.recommended %} - recommended - {% endif %} + {% if q.recommended %}recommended{% endif %} + {% if q.wallet_capable %}wallet{% endif %} {{ q.display_amount or "—" }} {% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} - {% if q.available %} - live - {% else %} - {{ q.reason or "unavailable" }} - {% endif %} - - - {% if q.available %}live{% else %}{{ q.reason or "unavailable" }}{% endif %}