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 zipfile
import smtplib import smtplib
import secrets import secrets
import threading
import time
from reportlab.lib.pagesizes import letter from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader 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_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") 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(): def load_version():
@ -388,6 +395,222 @@ def verify_wallet_transaction(option, tx_hash):
return seen_result 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): def square_amount_to_cents(value):
return int((to_decimal(value) * 100).quantize(Decimal("1"))) return int((to_decimal(value) * 100).quantize(Decimal("1")))
@ -4622,22 +4845,27 @@ def portal_submit_crypto_tx(invoice_id):
ensure_invoice_quote_columns() 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() 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() tx_hash = str(payload.get("tx_hash") or "").strip()
if not payment_id.isdigit(): if not payment_id.isdigit():
return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 return jsonify({"ok": False, "error": "invalid_payment_id"}), 400
if not re.fullmatch(r"0x[a-fA-F0-9]{64}", tx_hash): if not asset:
return jsonify({"ok": False, "error": "invalid_tx_hash"}), 400 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() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute(""" cursor.execute("""
SELECT id, client_id, invoice_number, status, total_amount, amount_paid, SELECT id, client_id, invoice_number, oracle_snapshot
quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot
FROM invoices FROM invoices
WHERE id = %s AND client_id = %s WHERE id = %s AND client_id = %s
LIMIT 1 LIMIT 1
@ -4655,50 +4883,50 @@ def portal_submit_crypto_tx(invoice_id):
except Exception: except Exception:
invoice["oracle_quote"] = None invoice["oracle_quote"] = None
options = get_invoice_crypto_options(invoice) crypto_options = get_invoice_crypto_options(invoice)
selected_option = next((o for o in options if o["symbol"] == asset_symbol), None) selected_option = next((o for o in crypto_options if o["symbol"] == asset), None)
if not selected_option: if not selected_option:
conn.close() conn.close()
return jsonify({"ok": False, "error": "asset_not_allowed"}), 400 return jsonify({"ok": False, "error": "invalid_asset"}), 400
if not selected_option.get("wallet_capable"):
conn.close()
return jsonify({"ok": False, "error": "asset_not_wallet_capable"}), 400
cursor.execute(""" cursor.execute("""
SELECT id, invoice_id, client_id, payment_currency, payment_amount, wallet_address, reference, SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes
payment_status, created_at, received_at, txid, notes
FROM payments FROM payments
WHERE id = %s WHERE id = %s
AND invoice_id = %s AND invoice_id = %s
AND client_id = %s AND client_id = %s
LIMIT 1 LIMIT 1
""", (payment_id, invoice_id, client["id"])) """, (int(payment_id), invoice_id, client["id"]))
payment = cursor.fetchone() payment = cursor.fetchone()
if not payment: if not payment:
conn.close() conn.close()
return jsonify({"ok": False, "error": "payment_not_found"}), 404 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() conn.close()
return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400
try: if str(payment.get("payment_status") or "").lower() != "pending":
verify_wallet_transaction(selected_option, tx_hash)
except Exception as err:
conn.close() 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 = conn.cursor()
update_cursor.execute(""" update_cursor.execute("""
UPDATE payments UPDATE payments
SET txid = %s, SET txid = %s,
received_at = UTC_TIMESTAMP(), payment_status = 'pending',
notes = CONCAT(COALESCE(notes, ''), %s) notes = %s
WHERE id = %s WHERE id = %s
""", ( """, (
tx_hash, tx_hash,
f" | tx_submitted | rpc_seen | chain:{selected_option['chain']} | asset:{selected_option['symbol']}", new_notes,
payment["id"] payment["id"]
)) ))
conn.commit() conn.commit()
@ -4706,14 +4934,10 @@ def portal_submit_crypto_tx(invoice_id):
return jsonify({ return jsonify({
"ok": True, "ok": True,
"payment_id": payment["id"], "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&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/<int:invoice_id>/pay-square", methods=["GET"]) @app.route("/portal/invoice/<int:invoice_id>/pay-square", methods=["GET"])
def portal_invoice_pay_square(invoice_id): def portal_invoice_pay_square(invoice_id):
client = _portal_current_client() client = _portal_current_client()

9
templates/portal_dashboard.html

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

22
templates/portal_invoice_detail.html

@ -155,7 +155,9 @@
<div class="detail-card"> <div class="detail-card">
<h3>Status</h3> <h3>Status</h3>
{% set s = (invoice.status or "")|lower %} {% 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 == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span>
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ 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 %} {% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %}
@ -219,7 +221,7 @@
{% if pending_crypto_payment and pending_crypto_payment.txid %} {% if pending_crypto_payment and pending_crypto_payment.txid %}
<div class="snapshot-timer-box"> <div class="snapshot-timer-box">
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> <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> </div>
{% elif pending_crypto_payment %} {% elif pending_crypto_payment %}
<div class="snapshot-timer-box"> <div class="snapshot-timer-box">
@ -299,7 +301,7 @@
{% elif pending_crypto_payment.txid %} {% elif pending_crypto_payment.txid %}
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> <div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div>
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> <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 %} {% elif pending_crypto_payment.lock_expired %}
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> <div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div>
{% endif %} {% endif %}
@ -308,7 +310,7 @@
{% if pending_crypto_payment and pending_crypto_payment.txid %} {% if pending_crypto_payment and pending_crypto_payment.txid %}
<div class="snapshot-timer-box"> <div class="snapshot-timer-box">
<div id="processingTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> <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> </div>
{% else %} {% else %}
<div class="snapshot-timer-box"> <div class="snapshot-timer-box">
@ -620,6 +622,18 @@ Reference: ${invoiceRef}`;
})(); })();
</script> </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" %} {% include "footer.html" %}
</body> </body>
</html> </html>

Loading…
Cancel
Save