Browse Source

Reconcile crypto tx on invoice refresh with 3 minute window

main
def 6 days ago
parent
commit
fadfefba7e
  1. 317
      backend/app.py
  2. 6
      templates/portal_invoice_detail.html

317
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()

6
templates/portal_invoice_detail.html

@ -221,7 +221,7 @@
{% if pending_crypto_payment and pending_crypto_payment.txid %}
<div class="snapshot-timer-box">
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div>
<div id="processingTimerLabel" class="snapshot-timer-label">Waiting for network confirmation / processing</div>
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div>
</div>
{% elif pending_crypto_payment %}
<div class="snapshot-timer-box">
@ -301,7 +301,7 @@
{% elif pending_crypto_payment.txid %}
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div>
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code>
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Waiting for network confirmation / processing.</div>
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div>
{% elif pending_crypto_payment.lock_expired %}
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div>
{% endif %}
@ -310,7 +310,7 @@
{% if pending_crypto_payment and pending_crypto_payment.txid %}
<div class="snapshot-timer-box">
<div id="processingTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div>
<div id="processingTimerSideLabel" class="snapshot-timer-label">Waiting for network confirmation / processing</div>
<div id="processingTimerSideLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div>
</div>
{% else %}
<div class="snapshot-timer-box">

Loading…
Cancel
Save