diff --git a/backend/app.py b/backend/app.py index 4c2d6a3..62b35d9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -17,6 +17,7 @@ import hashlib import base64 import urllib.request import urllib.error +import urllib.parse import uuid import re import zipfile @@ -46,6 +47,7 @@ SQUARE_WEBHOOK_NOTIFICATION_URL = os.getenv("SQUARE_WEBHOOK_NOTIFICATION_URL", " SQUARE_API_BASE = "https://connect.squareup.com" SQUARE_API_VERSION = "2026-01-22" SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") @@ -92,9 +94,90 @@ def fmt_money(value, currency_code="CAD"): return f"{amount:.2f}" return f"{amount:.8f}" +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1"))) @@ -2162,6 +2245,7 @@ def invoices(): @app.route("/invoices/new", methods=["GET", "POST"]) def new_invoice(): + ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -2230,6 +2314,12 @@ def new_invoice(): if notes: line_description = f"{service_name} - {notes}" + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO invoices @@ -2243,9 +2333,13 @@ def new_invoice(): issued_at, due_at, status, - notes + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) """, ( client_id, service_id, @@ -2254,7 +2348,11 @@ def new_invoice(): total_amount, total_amount, due_at, - notes + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json )) invoice_id = insert_cursor.lastrowid @@ -2514,6 +2612,7 @@ def invoice_pdf(invoice_id): @app.route("/invoices/view/") def view_invoice(invoice_id): + ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -2538,6 +2637,14 @@ def view_invoice(invoice_id): conn.close() return "Invoice not found", 404 + invoice["oracle_quote"] = None + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + conn.close() settings = get_app_settings() return render_template("invoices/view.html", invoice=invoice, settings=settings) @@ -3632,11 +3739,13 @@ def portal_invoice_detail(invoice_id): 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, created_at, total_amount, amount_paid + 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 @@ -3663,6 +3772,13 @@ def portal_invoice_detail(invoice_id): invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) invoice["created_at"] = fmt_local(invoice.get("created_at")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None for item in items: item["quantity"] = _fmt_money(item.get("quantity")) diff --git a/sql/schema_v0.0.2.sql b/sql/schema_v0.0.2.sql index fcf6707..cd119c1 100644 --- a/sql/schema_v0.0.2.sql +++ b/sql/schema_v0.0.2.sql @@ -68,6 +68,10 @@ CREATE TABLE invoices ( total_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, amount_paid DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, status ENUM('draft','pending','paid','partial','overdue','cancelled') NOT NULL DEFAULT 'draft', + quote_fiat_amount DECIMAL(18,8) DEFAULT NULL, + quote_fiat_currency VARCHAR(16) DEFAULT NULL, + quote_expires_at DATETIME DEFAULT NULL, + oracle_snapshot LONGTEXT DEFAULT NULL, issued_at DATETIME DEFAULT NULL, due_at DATETIME DEFAULT NULL, paid_at DATETIME DEFAULT NULL, diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index 94d9533..7323853 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -75,6 +75,41 @@ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } + .quote-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + } + .quote-table th, .quote-table td { + padding: 0.75rem; + border-bottom: 1px solid rgba(255,255,255,0.12); + text-align: left; + } + .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; + 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; + } @@ -188,6 +223,53 @@ {% endif %} + {% 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" }}
+
+ + + + + + + + + + + + {% 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 + {% else %} + {{ q.reason or "unavailable" }} + {% endif %} +
+

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

+
+ {% endif %} + {% if pdf_url %}
Open Invoice PDF