You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
585 lines
22 KiB
585 lines
22 KiB
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Invoice Detail - OutsideTheBox</title> |
|
<link rel="stylesheet" href="/static/css/style.css"> |
|
<style> |
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
|
.portal-top { |
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
|
margin-bottom: 1rem; |
|
} |
|
.portal-actions a { |
|
margin-left: 0.75rem; |
|
text-decoration: underline; |
|
} |
|
.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; |
|
} |
|
.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 { |
|
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; |
|
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; |
|
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 { |
|
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; |
|
} |
|
} |
|
</style> |
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
|
</head> |
|
<body> |
|
<div class="portal-wrap"> |
|
<div class="portal-top"> |
|
<div> |
|
<h1>Invoice Detail</h1> |
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
|
</div> |
|
<div class="portal-actions"> |
|
<a href="/portal/dashboard">Back to Dashboard</a> |
|
<a href="https://outsidethebox.top/">Home</a> |
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
|
<a href="/portal/logout">Logout</a> |
|
</div> |
|
</div> |
|
|
|
{% 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);"> |
|
✓ This invoice has been paid. Thank you! |
|
</div> |
|
{% endif %} |
|
|
|
{% if crypto_error %} |
|
<div class="error-box">{{ crypto_error }}</div> |
|
{% endif %} |
|
|
|
<div class="detail-grid"> |
|
<div class="detail-card"> |
|
<h3>Invoice</h3> |
|
<div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div> |
|
</div> |
|
<div class="detail-card"> |
|
<h3>Status</h3> |
|
{% set s = (invoice.status or "")|lower %} |
|
{% if 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 == "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> |
|
|
|
<h2>Invoice Items</h2> |
|
<table class="portal-table"> |
|
<thead> |
|
<tr> |
|
<th>Description</th> |
|
<th>Qty</th> |
|
<th>Unit Price</th> |
|
<th>Line Total</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
{% for item in items %} |
|
<tr> |
|
<td>{{ item.description }}</td> |
|
<td>{{ item.quantity }}</td> |
|
<td>{{ item.unit_price }}</td> |
|
<td>{{ item.line_total }}</td> |
|
</tr> |
|
{% else %} |
|
<tr> |
|
<td colspan="4">No invoice line items found.</td> |
|
</tr> |
|
{% endfor %} |
|
</tbody> |
|
</table> |
|
|
|
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} |
|
<div class="pay-card"> |
|
<h3>Pay Now</h3> |
|
<div class="pay-selector-row"> |
|
<label for="payMethodSelect"><strong>Choose payment method:</strong></label> |
|
<select id="payMethodSelect" class="pay-selector"> |
|
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> |
|
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> |
|
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> |
|
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> |
|
</select> |
|
</div> |
|
|
|
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> |
|
<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> |
|
</div> |
|
|
|
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> |
|
<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"> |
|
Pay with Credit Card |
|
</a> |
|
</div> |
|
|
|
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> |
|
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} |
|
<div class="snapshot-wrap"> |
|
<div class="snapshot-header"> |
|
<div class="snapshot-meta"> |
|
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3> |
|
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_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> |
|
{% if pending_crypto_payment %} |
|
<div style="margin-top:0.75rem;"><strong>Price locked for 2 minutes after acceptance.</strong></div> |
|
{% else %} |
|
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> |
|
{% endif %} |
|
</div> |
|
|
|
{% if pending_crypto_payment %} |
|
<div class="snapshot-timer-box"> |
|
<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> |
|
{% else %} |
|
<div class="snapshot-timer-box"> |
|
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> |
|
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> |
|
</div> |
|
{% endif %} |
|
</div> |
|
|
|
{% if pending_crypto_payment and selected_crypto_option %} |
|
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired %} expired{% endif %}"> |
|
<div class="lock-grid"> |
|
<div> |
|
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> |
|
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> |
|
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> |
|
<code class="lock-code">{{ pending_crypto_payment.wallet_address }}</code> |
|
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
|
<code class="lock-code">{{ pending_crypto_payment.reference }}</code> |
|
<div style="margin-top:0.65rem;"> |
|
{% 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 %} |
|
</div> |
|
</div> |
|
<div class="snapshot-timer-box"> |
|
<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> |
|
</div> |
|
</div> |
|
{% else %} |
|
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> |
|
<table class="quote-table"> |
|
<thead> |
|
<tr> |
|
<th>Asset</th> |
|
<th>Quoted Amount</th> |
|
<th>CAD Price</th> |
|
<th>Status</th> |
|
<th>Action</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
{% for q in crypto_options %} |
|
<tr> |
|
<td> |
|
{{ q.label }} |
|
{% 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> |
|
<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> |
|
{% endfor %} |
|
</tbody> |
|
</table> |
|
</form> |
|
{% endif %} |
|
</div> |
|
{% else %} |
|
<p>No crypto quote snapshot is available for this invoice yet.</p> |
|
{% endif %} |
|
</div> |
|
</div> |
|
{% endif %} |
|
|
|
{% if pdf_url %} |
|
<div style="margin-top:1rem;"> |
|
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a> |
|
</div> |
|
{% endif %} |
|
</div> |
|
|
|
<script> |
|
(function() { |
|
const select = document.getElementById("payMethodSelect"); |
|
if (select) { |
|
select.addEventListener("change", function() { |
|
const value = this.value || ""; |
|
const url = new URL(window.location.href); |
|
if (!value) { |
|
url.searchParams.delete("pay"); |
|
url.searchParams.delete("asset"); |
|
url.searchParams.delete("payment_id"); |
|
url.searchParams.delete("crypto_error"); |
|
url.searchParams.delete("refresh_quote"); |
|
} else { |
|
url.searchParams.set("pay", value); |
|
if (value !== "crypto") { |
|
url.searchParams.delete("asset"); |
|
url.searchParams.delete("payment_id"); |
|
url.searchParams.delete("crypto_error"); |
|
url.searchParams.delete("refresh_quote"); |
|
} else { |
|
url.searchParams.delete("asset"); |
|
url.searchParams.delete("payment_id"); |
|
url.searchParams.delete("crypto_error"); |
|
url.searchParams.set("refresh_quote", "1"); |
|
} |
|
} |
|
window.location.href = url.toString(); |
|
}); |
|
} |
|
|
|
function bindCountdown(valueId, labelId, expireIso, expiredMessage) { |
|
const valueEl = document.getElementById(valueId); |
|
const labelEl = document.getElementById(labelId); |
|
if (!valueEl || !expireIso) return; |
|
|
|
function tick() { |
|
const end = new Date(expireIso).getTime(); |
|
const now = Date.now(); |
|
const diff = Math.max(0, Math.floor((end - now) / 1000)); |
|
|
|
if (diff <= 0) { |
|
valueEl.textContent = "00:00"; |
|
valueEl.classList.add("snapshot-timer-expired"); |
|
if (labelEl) { |
|
labelEl.textContent = expiredMessage; |
|
labelEl.classList.add("snapshot-timer-expired"); |
|
} |
|
const cryptoForm = document.getElementById("cryptoPickForm"); |
|
if (cryptoForm) { |
|
cryptoForm.querySelectorAll("button").forEach(btn => btn.disabled = true); |
|
} |
|
const lockBox = document.getElementById("lockBox"); |
|
if (lockBox) { |
|
lockBox.classList.add("expired"); |
|
} |
|
return; |
|
} |
|
|
|
const m = String(Math.floor(diff / 60)).padStart(2, "0"); |
|
const s = String(diff % 60).padStart(2, "0"); |
|
valueEl.textContent = `${m}:${s}`; |
|
setTimeout(tick, 250); |
|
} |
|
|
|
tick(); |
|
} |
|
|
|
const quoteTimer = document.getElementById("quoteTimerValue"); |
|
if (quoteTimer && quoteTimer.dataset.expiry) { |
|
bindCountdown( |
|
"quoteTimerValue", |
|
"quoteTimerLabel", |
|
quoteTimer.dataset.expiry, |
|
"price has expired - please refresh your view to update" |
|
); |
|
} |
|
|
|
const lockTimer = document.getElementById("lockTimerValue"); |
|
if (lockTimer && lockTimer.dataset.expiry) { |
|
bindCountdown( |
|
"lockTimerValue", |
|
"lockTimerLabel", |
|
lockTimer.dataset.expiry, |
|
"price has expired - please refresh your quote to update" |
|
); |
|
} |
|
|
|
const lockTimerSide = document.getElementById("lockTimerSideValue"); |
|
if (lockTimerSide && lockTimerSide.dataset.expiry) { |
|
bindCountdown( |
|
"lockTimerSideValue", |
|
"lockTimerSideLabel", |
|
lockTimerSide.dataset.expiry, |
|
"price has expired - please refresh your quote to update" |
|
); |
|
} |
|
})(); |
|
</script> |
|
|
|
{% include "footer.html" %} |
|
</body> |
|
</html>
|
|
|