Browse Source

Add crypto processing watcher and portal auto-refresh

main
def 6 days ago
parent
commit
fc9e9ddfb0
  1. 282
      backend/app.py
  2. 9
      templates/portal_dashboard.html
  3. 22
      templates/portal_invoice_detail.html

282
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/<int:invoice_id>/pay-square", methods=["GET"])
def portal_invoice_pay_square(invoice_id):
client = _portal_current_client()

9
templates/portal_dashboard.html

@ -148,6 +148,15 @@
</table>
</div>
<script>
(function() {
setTimeout(function() {
window.location.reload();
}, 20000);
})();
</script>
{% include "footer.html" %}
</body>
</html>

22
templates/portal_invoice_detail.html

@ -155,7 +155,9 @@
<div class="detail-card">
<h3>Status</h3>
{% set s = (invoice.status or "")|lower %}
{% if s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span>
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %}
<span class="status-badge status-pending">processing</span>
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span>
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span>
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span>
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %}
@ -219,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</div>
<div id="processingTimerLabel" class="snapshot-timer-label">Waiting for network confirmation / processing</div>
</div>
{% elif pending_crypto_payment %}
<div class="snapshot-timer-box">
@ -299,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.</div>
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Waiting for network confirmation / processing.</div>
{% elif pending_crypto_payment.lock_expired %}
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div>
{% endif %}
@ -308,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</div>
<div id="processingTimerSideLabel" class="snapshot-timer-label">Waiting for network confirmation / processing</div>
</div>
{% else %}
<div class="snapshot-timer-box">
@ -620,6 +622,18 @@ Reference: ${invoiceRef}`;
})();
</script>
<script>
(function() {
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }};
if (processingAutoRefreshEnabled) {
setTimeout(function() {
window.location.reload();
}, 10000);
}
})();
</script>
{% include "footer.html" %}
</body>
</html>

Loading…
Cancel
Save