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