Browse Source

Add wallet-driven crypto payment submission for ETH and USDC

main
def 7 days ago
parent
commit
5e43b0f203
  1. 236
      backend/app.py
  2. 575
      templates/portal_invoice_detail.html

236
backend/app.py

@ -20,6 +20,7 @@ import urllib.error
import urllib.parse import urllib.parse
import uuid import uuid
import re import re
import math
import zipfile import zipfile
import smtplib import smtplib
import secrets import secrets
@ -49,6 +50,8 @@ SQUARE_API_VERSION = "2026-01-22"
SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log")
ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top")
CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") 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)", "label": "USDC (Arbitrum)",
"payment_currency": "USDC", "payment_currency": "USDC",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
"wallet_capable": True,
"asset_type": "token",
"chain_id": 42161,
"decimals": 6,
"token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
}, },
"ETH": { "ETH": {
"symbol": "ETH", "symbol": "ETH",
@ -198,6 +206,11 @@ def get_invoice_crypto_options(invoice):
"label": "ETH (Ethereum)", "label": "ETH (Ethereum)",
"payment_currency": "ETH", "payment_currency": "ETH",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
"wallet_capable": True,
"asset_type": "native",
"chain_id": 1,
"decimals": 18,
"token_contract": None,
}, },
"ETHO": { "ETHO": {
"symbol": "ETHO", "symbol": "ETHO",
@ -205,6 +218,11 @@ def get_invoice_crypto_options(invoice):
"label": "ETHO (Etho)", "label": "ETHO (Etho)",
"payment_currency": "ETHO", "payment_currency": "ETHO",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
"wallet_capable": False,
"asset_type": "native",
"chain_id": None,
"decimals": 18,
"token_contract": None,
}, },
"ETI": { "ETI": {
"symbol": "ETI", "symbol": "ETI",
@ -212,6 +230,11 @@ def get_invoice_crypto_options(invoice):
"label": "ETI (Etica)", "label": "ETI (Etica)",
"payment_currency": "ETI", "payment_currency": "ETI",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, "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"))) options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol")))
return options 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): def square_amount_to_cents(value):
return int((to_decimal(value) * 100).quantize(Decimal("1"))) return int((to_decimal(value) * 100).quantize(Decimal("1")))
@ -3880,7 +3998,7 @@ def portal_invoice_detail(invoice_id):
if payment_id.isdigit(): if payment_id.isdigit():
cursor.execute(""" cursor.execute("""
SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, 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 FROM payments
WHERE id = %s WHERE id = %s
AND invoice_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_expires_at_iso"] = ""
pending_crypto_payment["lock_expired"] = True 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: if not selected_crypto_option:
selected_crypto_option = next( selected_crypto_option = next(
(o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), (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}") return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}")
@app.route("/portal/invoice/<int:invoice_id>/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/<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()

575
templates/portal_invoice_detail.html

@ -7,252 +7,59 @@
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<style> <style>
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } .portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top { .portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; }
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; .portal-actions a { margin-left: 0.75rem; text-decoration: underline; }
margin-bottom: 1rem; .detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; }
} .detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; }
.portal-actions a { .detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; }
margin-left: 0.75rem; table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
text-decoration: underline; table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; }
} table.portal-table th { background: #e9eef7; color: #10203f; }
.detail-grid { .status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; }
display:grid; .status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); .status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; }
gap:1rem; .status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; }
margin: 1rem 0 1.25rem 0; .status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; }
}
.detail-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.03);
margin-bottom: 1rem;
}
.detail-card h3 {
margin-top: 0;
margin-bottom: 0.4rem;
}
table.portal-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
table.portal-table th, table.portal-table td {
padding: 0.8rem;
border-bottom: 1px solid rgba(255,255,255,0.12);
text-align: left;
}
table.portal-table th {
background: #e9eef7;
color: #10203f;
}
.status-badge {
display: inline-block;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 700;
}
.status-paid {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.status-pending {
background: rgba(245, 158, 11, 0.20);
color: #fbbf24;
}
.status-overdue {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.status-other {
background: rgba(148, 163, 184, 0.20);
color: #cbd5e1;
}
.pay-card { .pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; }
border: 1px solid rgba(255,255,255,0.16); .pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; }
border-radius: 14px; .pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; }
padding: 1rem; .pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); }
background: rgba(255,255,255,0.03); .pay-panel.hidden { display: none; }
margin-top: 1.25rem; .pay-btn { display:inline-block; padding:12px 18px; color:#ffffff; text-decoration:none; border-radius:8px; font-weight:700; border:none; cursor:pointer; margin:8px 0 0 0; }
} .pay-btn-square { background:#16a34a; }
.pay-selector-row { .pay-btn-wallet { background:#2563eb; }
display:flex; .error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; }
gap:0.75rem; .success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; }
align-items:center;
flex-wrap:wrap;
margin-top:0.75rem;
}
.pay-selector {
padding: 10px 12px;
min-width: 220px;
border-radius: 8px;
}
.pay-panel {
margin-top: 1rem;
padding: 1rem;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
background: rgba(255,255,255,0.02);
}
.pay-panel.hidden {
display: none;
}
.pay-btn {
display:inline-block;
padding:12px 18px;
color:#ffffff;
text-decoration:none;
border-radius:8px;
font-weight:700;
border:none;
cursor:pointer;
margin:8px 0 0 0;
}
.pay-btn-square {
background:#16a34a;
}
.pay-btn-crypto {
background:#2563eb;
}
.error-box {
border: 1px solid rgba(239, 68, 68, 0.55);
background: rgba(127, 29, 29, 0.22);
color: #fecaca;
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 1rem;
}
.snapshot-wrap { .snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); }
position: relative; .snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; }
margin-top: 1rem; .snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; }
border: 1px solid rgba(255,255,255,0.14); .snapshot-timer-box { width: 220px; min-height: 132px; border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; background: rgba(0,0,0,0.18); display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; padding: 0.9rem; }
border-radius: 14px; .snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; }
padding: 1rem; .snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; }
background: rgba(255,255,255,0.02); .snapshot-timer-expired { color: #f87171; }
}
.snapshot-header {
display:flex;
justify-content:space-between;
gap:1rem;
align-items:flex-start;
}
.snapshot-meta {
flex: 1 1 auto;
min-width: 0;
line-height: 1.65;
}
.snapshot-timer-box {
width: 220px;
min-height: 132px;
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
background: rgba(0,0,0,0.18);
display:flex;
flex-direction:column;
justify-content:center;
align-items:center;
text-align:center;
padding: 0.9rem;
}
.snapshot-timer-value {
font-size: 2rem;
font-weight: 800;
line-height: 1.1;
}
.snapshot-timer-label {
margin-top: 0.55rem;
font-size: 0.95rem;
opacity: 0.95;
}
.snapshot-timer-expired {
color: #f87171;
}
.quote-table { .quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
width: 100%; .quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; }
border-collapse: collapse; .quote-table th { background: #e9eef7; color: #10203f; }
margin-top: 1rem; .quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; }
} .quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
.quote-table th, .quote-table td { .quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; }
padding: 0.75rem; .quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; }
border-bottom: 1px solid rgba(255,255,255,0.12); .quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
text-align: left;
vertical-align: top;
}
.quote-table th {
background: #e9eef7;
color: #10203f;
}
.quote-badge {
display: inline-block;
padding: 0.14rem 0.48rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
margin-left: 0.4rem;
}
.quote-live {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.quote-stale {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.quote-pick-btn {
padding: 8px 12px;
border-radius: 8px;
border: none;
background: #2563eb;
color: #fff;
font-weight: 700;
cursor: pointer;
}
.quote-pick-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.lock-box { .lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; }
margin-top: 1rem; .lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); }
border: 1px solid rgba(34, 197, 94, 0.28); .lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; }
background: rgba(22, 101, 52, 0.16); .lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; }
border-radius: 12px; .wallet-actions { display:flex; gap:0.75rem; flex-wrap:wrap; margin-top:0.9rem; align-items:center; }
padding: 1rem; .wallet-note { opacity:0.9; margin-top:0.65rem; }
} .mono { font-family: monospace; }
.lock-box.expired {
border-color: rgba(239, 68, 68, 0.55);
background: rgba(127, 29, 29, 0.22);
}
.lock-grid {
display:grid;
grid-template-columns: 1fr 220px;
gap:1rem;
align-items:start;
}
.lock-code {
display:block;
margin-top:0.35rem;
padding:0.65rem 0.8rem;
background: rgba(0,0,0,0.22);
border-radius: 8px;
overflow-wrap:anywhere;
}
@media (max-width: 820px) { @media (max-width: 820px) {
.snapshot-header, .snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; }
.lock-grid { .snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; }
grid-template-columns: 1fr;
display:block;
}
.snapshot-timer-box {
width: 100%;
margin-top: 1rem;
min-height: 110px;
}
} }
</style> </style>
<link rel="icon" type="image/png" href="/static/favicon.png"> <link rel="icon" type="image/png" href="/static/favicon.png">
@ -273,9 +80,7 @@
</div> </div>
{% if (invoice.status or "")|lower == "paid" %} {% if (invoice.status or "")|lower == "paid" %}
<div style="background:#166534;color:#ecfdf5;padding:12px 14px;margin:0 0 20px 0;border-radius:8px;font-weight:600;border:1px solid rgba(255,255,255,0.12);"> <div class="success-box">✓ This invoice has been paid. Thank you!</div>
✓ This invoice has been paid. Thank you!
</div>
{% endif %} {% endif %}
{% if crypto_error %} {% if crypto_error %}
@ -283,63 +88,29 @@
{% endif %} {% endif %}
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-card"> <div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div>
<h3>Invoice</h3>
<div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div>
</div>
<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" %} {% if s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span>
<span class="status-badge status-paid">{{ invoice.status }}</span> {% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span>
{% elif s == "pending" %} {% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span>
<span class="status-badge status-pending">{{ invoice.status }}</span> {% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %}
{% elif s == "overdue" %}
<span class="status-badge status-overdue">{{ invoice.status }}</span>
{% else %}
<span class="status-badge status-other">{{ invoice.status }}</span>
{% endif %}
</div>
<div class="detail-card">
<h3>Created</h3>
<div>{{ invoice.created_at }}</div>
</div>
<div class="detail-card">
<h3>Total</h3>
<div>{{ invoice.total_amount }}</div>
</div>
<div class="detail-card">
<h3>Paid</h3>
<div>{{ invoice.amount_paid }}</div>
</div>
<div class="detail-card">
<h3>Outstanding</h3>
<div>{{ invoice.outstanding }}</div>
</div> </div>
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div>
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div>
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div>
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div>
</div> </div>
<h2>Invoice Items</h2> <h2>Invoice Items</h2>
<table class="portal-table"> <table class="portal-table">
<thead> <thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th>Line Total</th>
</tr>
</thead>
<tbody> <tbody>
{% for item in items %} {% for item in items %}
<tr> <tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unit_price }}</td>
<td>{{ item.line_total }}</td>
</tr>
{% else %} {% else %}
<tr> <tr><td colspan="4">No invoice line items found.</td></tr>
<td colspan="4">No invoice line items found.</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -358,17 +129,12 @@
</div> </div>
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> <div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}">
<p><strong>Interac e-Transfer</strong><br> <p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p>
Send payment to:<br>
payment@outsidethebox.top<br>
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p>
</div> </div>
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> <div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}">
<p><strong>Credit Card (Square)</strong></p> <p><strong>Credit Card (Square)</strong></p>
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square"> <a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a>
Pay with Credit Card
</a>
</div> </div>
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> <div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}">
@ -387,7 +153,12 @@
{% endif %} {% endif %}
</div> </div>
{% if pending_crypto_payment %} {% 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>
{% elif pending_crypto_payment %}
<div class="snapshot-timer-box"> <div class="snapshot-timer-box">
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> <div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div>
<div id="lockTimerLabel" class="snapshot-timer-label">This price is locked for 2 minutes</div> <div id="lockTimerLabel" class="snapshot-timer-label">This price is locked for 2 minutes</div>
@ -401,7 +172,7 @@
</div> </div>
{% if pending_crypto_payment and selected_crypto_option %} {% if pending_crypto_payment and selected_crypto_option %}
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired %} expired{% endif %}"> <div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}">
<div class="lock-grid"> <div class="lock-grid">
<div> <div>
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> <h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3>
@ -410,55 +181,68 @@
<code class="lock-code">{{ pending_crypto_payment.wallet_address }}</code> <code class="lock-code">{{ pending_crypto_payment.wallet_address }}</code>
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> <div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div>
<code class="lock-code">{{ pending_crypto_payment.reference }}</code> <code class="lock-code">{{ pending_crypto_payment.reference }}</code>
<div style="margin-top:0.65rem;">
{% if pending_crypto_payment.lock_expired %} {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update <div class="wallet-actions">
{% else %} <button
Your selected crypto quote has been accepted and placed into processing. type="button"
{% endif %} id="walletPayButton"
class="pay-btn pay-btn-wallet"
data-invoice-id="{{ invoice.id }}"
data-payment-id="{{ pending_crypto_payment.id }}"
data-asset="{{ selected_crypto_option.symbol }}"
data-chain-id="{{ selected_crypto_option.chain_id }}"
data-asset-type="{{ selected_crypto_option.asset_type }}"
data-to="{{ selected_crypto_option.wallet_address }}"
data-amount="{{ pending_crypto_payment.payment_amount }}"
data-decimals="{{ selected_crypto_option.decimals }}"
data-token-contract="{{ selected_crypto_option.token_contract or '' }}"
>
Pay with MetaMask / Rabby
</button>
<span id="walletStatusText"></span>
</div>
<div class="wallet-note">
This will open your wallet, prepare the exact transaction, and submit the tx hash back to the portal automatically.
</div> </div>
{% 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>
{% elif pending_crypto_payment.lock_expired %}
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div>
{% endif %}
</div> </div>
{% 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>
{% else %}
<div class="snapshot-timer-box"> <div class="snapshot-timer-box">
<div id="lockTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> <div id="lockTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div>
<div id="lockTimerSideLabel" class="snapshot-timer-label">This price is locked for 2 minutes</div> <div id="lockTimerSideLabel" class="snapshot-timer-label">This price is locked for 2 minutes</div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> <form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto">
<table class="quote-table"> <table class="quote-table">
<thead> <thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead>
<tr>
<th>Asset</th>
<th>Quoted Amount</th>
<th>CAD Price</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody> <tbody>
{% for q in crypto_options %} {% for q in crypto_options %}
<tr> <tr>
<td> <td>
{{ q.label }} {{ q.label }}
{% if q.recommended %} {% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %}
<span class="quote-badge quote-live">recommended</span> {% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %}
{% endif %}
</td> </td>
<td>{{ q.display_amount or "—" }}</td> <td>{{ q.display_amount or "—" }}</td>
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> <td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td>
<td> <td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td>
{% if q.available %} <td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td>
<span class="quote-badge quote-live">live</span>
{% else %}
<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>
{% endif %}
</td>
<td>
<button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>
Accept {{ q.symbol }}
</button>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -474,9 +258,7 @@
{% endif %} {% endif %}
{% if pdf_url %} {% if pdf_url %}
<div style="margin-top:1rem;"> <div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div>
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a>
</div>
{% endif %} {% endif %}
</div> </div>
@ -511,7 +293,7 @@
}); });
} }
function bindCountdown(valueId, labelId, expireIso, expiredMessage) { function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) {
const valueEl = document.getElementById(valueId); const valueEl = document.getElementById(valueId);
const labelEl = document.getElementById(labelId); const labelEl = document.getElementById(labelId);
if (!valueEl || !expireIso) return; if (!valueEl || !expireIso) return;
@ -528,14 +310,11 @@
labelEl.textContent = expiredMessage; labelEl.textContent = expiredMessage;
labelEl.classList.add("snapshot-timer-expired"); labelEl.classList.add("snapshot-timer-expired");
} }
const cryptoForm = document.getElementById("cryptoPickForm"); if (disableSelector) {
if (cryptoForm) { document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true);
cryptoForm.querySelectorAll("button").forEach(btn => btn.disabled = true);
} }
const lockBox = document.getElementById("lockBox"); const lockBox = document.getElementById("lockBox");
if (lockBox) { if (lockBox) lockBox.classList.add("expired");
lockBox.classList.add("expired");
}
return; return;
} }
@ -550,32 +329,116 @@
const quoteTimer = document.getElementById("quoteTimerValue"); const quoteTimer = document.getElementById("quoteTimerValue");
if (quoteTimer && quoteTimer.dataset.expiry) { if (quoteTimer && quoteTimer.dataset.expiry) {
bindCountdown( bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button");
"quoteTimerValue",
"quoteTimerLabel",
quoteTimer.dataset.expiry,
"price has expired - please refresh your view to update"
);
} }
const lockTimer = document.getElementById("lockTimerValue"); const lockTimer = document.getElementById("lockTimerValue");
if (lockTimer && lockTimer.dataset.expiry) { if (lockTimer && lockTimer.dataset.expiry) {
bindCountdown( bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton");
"lockTimerValue",
"lockTimerLabel",
lockTimer.dataset.expiry,
"price has expired - please refresh your quote to update"
);
} }
const lockTimerSide = document.getElementById("lockTimerSideValue"); const lockTimerSide = document.getElementById("lockTimerSideValue");
if (lockTimerSide && lockTimerSide.dataset.expiry) { if (lockTimerSide && lockTimerSide.dataset.expiry) {
bindCountdown( bindCountdown("lockTimerSideValue", "lockTimerSideLabel", lockTimerSide.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton");
"lockTimerSideValue", }
"lockTimerSideLabel",
lockTimerSide.dataset.expiry, const processingTimer = document.getElementById("processingTimerValue");
"price has expired - please refresh your quote to update" 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;
}
});
} }
})(); })();
</script> </script>

Loading…
Cancel
Save