Browse Source

Freeze oracle quote snapshot on invoice creation

main
def 7 days ago
parent
commit
0f51253b3a
  1. 124
      backend/app.py
  2. 4
      sql/schema_v0.0.2.sql
  3. 82
      templates/portal_invoice_detail.html

124
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/<int:invoice_id>")
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"))

4
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,

82
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;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
@ -188,6 +223,53 @@
</div>
{% endif %}
{% if invoice.oracle_quote and invoice.oracle_quote.quotes %}
<div class="detail-card" style="margin-top:1.25rem;">
<h3>Crypto Quote Snapshot</h3>
<div class="quote-meta">
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div>
<div><strong>Quote Expires:</strong> {{ invoice.quote_expires_at_local or (invoice.oracle_quote.expires_at or "—") }}</div>
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div>
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div>
</div>
<table class="quote-table">
<thead>
<tr>
<th>Asset</th>
<th>Quoted Amount</th>
<th>CAD Price</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for q in invoice.oracle_quote.quotes %}
<tr>
<td>
{{ q.symbol }} {% if q.chain %}({{ q.chain }}){% endif %}
{% if q.recommended %}
<span class="quote-badge quote-live">recommended</span>
{% endif %}
</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.available %}
<span class="quote-badge quote-live">live</span>
{% else %}
<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:0.85rem; opacity:0.9;">
These crypto values were frozen when the invoice was created and are retained for audit/reference.
</p>
</div>
{% endif %}
{% if pdf_url %}
<div class="invoice-actions">
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a>

Loading…
Cancel
Save