From e631309322bb6089d34a32c99d237bb22a5163ab Mon Sep 17 00:00:00 2001 From: def Date: Sun, 15 Mar 2026 05:07:19 +0000 Subject: [PATCH] Redesign portal invoice payments with Pay Now selector and crypto timers --- backend/app.py | 259 +++++++++++++++ templates/portal_invoice_detail.html | 459 ++++++++++++++++++++++----- 2 files changed, 640 insertions(+), 78 deletions(-) diff --git a/backend/app.py b/backend/app.py index 62b35d9..50a7cbc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -179,6 +179,61 @@ def fetch_oracle_quote_snapshot(currency_code, total_amount): except Exception: return None +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1"))) @@ -3785,6 +3840,77 @@ def portal_invoice_detail(invoice_id): item["unit_price"] = _fmt_money(item.get("unit_price")) item["line_total"] = _fmt_money(item.get("line_total")) + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + pdf_url = f"/invoices/pdf/{invoice_id}" conn.close() @@ -3795,6 +3921,13 @@ def portal_invoice_detail(invoice_id): invoice=invoice, items=items, pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, ) @@ -4185,6 +4318,132 @@ OutsideTheBox +@app.route("/portal/invoice//pay-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + 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, + created_at + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + 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) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_id: + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO payments + ( + invoice_id, + client_id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + received_at, + notes + ) + VALUES (%s, %s, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + @app.route("/portal/invoice//pay-square", methods=["GET"]) def portal_invoice_pay_square(invoice_id): client = _portal_current_client() diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index 7323853..25fe781 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -26,6 +26,7 @@ border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); + margin-bottom: 1rem; } .detail-card h3 { margin-top: 0; @@ -45,13 +46,6 @@ background: #e9eef7; color: #10203f; } - .invoice-actions { - margin-top: 1rem; - } - .invoice-actions a { - margin-right: 1rem; - text-decoration: underline; - } .status-badge { display: inline-block; padding: 0.18rem 0.55rem; @@ -75,6 +69,108 @@ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } + + .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; + } + .pay-selector-row { + display:flex; + gap:0.75rem; + 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 { + 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); + } + .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 { width: 100%; border-collapse: collapse; @@ -84,16 +180,12 @@ padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; + vertical-align: top; } .quote-table th { background: #e9eef7; color: #10203f; } - .quote-meta { - font-size: 0.95rem; - line-height: 1.6; - opacity: 0.95; - } .quote-badge { display: inline-block; padding: 0.14rem 0.48rem; @@ -110,6 +202,58 @@ 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 { + margin-top: 1rem; + border: 1px solid rgba(34, 197, 94, 0.28); + background: rgba(22, 101, 52, 0.16); + border-radius: 12px; + padding: 1rem; + } + .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) { + .snapshot-header, + .lock-grid { + grid-template-columns: 1fr; + display:block; + } + .snapshot-timer-box { + width: 100%; + margin-top: 1rem; + min-height: 110px; + } + } @@ -128,13 +272,16 @@ - {% if (invoice.status or "")|lower == "paid" %}
✓ This invoice has been paid. Thank you!
{% endif %} + {% if crypto_error %} +
{{ crypto_error }}
+ {% endif %} +

Invoice

@@ -197,86 +344,242 @@ + {% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} +
+

Pay Now

+
+ + +
- {% if (invoice.status or "")|lower != "paid" %} -
-

Payment Instructions

- -

Interac e-Transfer
- Send payment to:
- payment@outsidethebox.top
- Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }} -

- -

Credit Card (Square)
- - Pay Now -
- Please include your invoice number in the payment note. -

- -

- If you have questions please contact - support@outsidethebox.top -

-
- {% endif %} +
+

Interac e-Transfer
+ Send payment to:
+ payment@outsidethebox.top
+ Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

+
- {% if invoice.oracle_quote and invoice.oracle_quote.quotes %} -
-

Crypto Quote Snapshot

-
-
Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
-
Quote Expires: {{ invoice.quote_expires_at_local or (invoice.oracle_quote.expires_at or "—") }}
-
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
-
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
+
+

Credit Card (Square)

+ + Pay with Credit Card +
- - - - - - - - - - - {% for q in invoice.oracle_quote.quotes %} - - - - - - - {% endfor %} - -
AssetQuoted AmountCAD PriceStatus
- {{ q.symbol }} {% if q.chain %}({{ q.chain }}){% endif %} - {% if q.recommended %} - recommended - {% endif %} - {{ q.display_amount or "—" }}{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} - {% if q.available %} - live +
+ {% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} +
+
+
+

Crypto Quote Snapshot

+
Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
+
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
+
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
+ {% if pending_crypto_payment %} +
Price locked for 2 minutes after acceptance.
+ {% else %} +
Select a crypto asset to accept the quote.
+ {% endif %} +
+ + {% if pending_crypto_payment %} +
+
--:--
+
This price is locked for 2 minutes
+
{% else %} - {{ q.reason or "unavailable" }} +
+
--:--
+
This price times out:
+
{% endif %} -
-

- These crypto values were frozen when the invoice was created and are retained for audit/reference. -

+
+ + {% if pending_crypto_payment and selected_crypto_option %} +
+
+
+

{{ selected_crypto_option.label }} Payment Instructions

+
Send exactly: {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}
+
Destination wallet:
+ {{ pending_crypto_payment.wallet_address }} +
Reference / Invoice:
+ {{ pending_crypto_payment.reference }} +
+ {% if pending_crypto_payment.lock_expired %} + price has expired - please refresh your quote to update + {% else %} + Your selected crypto quote has been accepted and placed into processing. + {% endif %} +
+
+
+
--:--
+
This price is locked for 2 minutes
+
+
+
+ {% else %} +
+ + + + + + + + + + + + {% for q in crypto_options %} + + + + + + + + {% endfor %} + +
AssetQuoted AmountCAD PriceStatusAction
+ {{ q.label }} + {% if q.recommended %} + recommended + {% endif %} + {{ q.display_amount or "—" }}{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} + {% if q.available %} + live + {% else %} + {{ q.reason or "unavailable" }} + {% endif %} + + +
+
+ {% endif %} +
+ {% else %} +

No crypto quote snapshot is available for this invoice yet.

+ {% endif %} +
{% endif %} {% if pdf_url %} -
+ {% endif %}
+ + {% include "footer.html" %}