Compare commits
21 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
735b53588d | 6 days ago |
|
|
4bd7fd5267 | 6 days ago |
|
|
19531c68c6 | 6 days ago |
|
|
7692a56458 | 6 days ago |
|
|
7a06680250 | 6 days ago |
|
|
717f4010a7 | 6 days ago |
|
|
2d4a104b0b | 6 days ago |
|
|
fadfefba7e | 6 days ago |
|
|
fc9e9ddfb0 | 6 days ago |
|
|
309209c5c0 | 6 days ago |
|
|
0efa554260 | 6 days ago |
|
|
3ba7b07d1e | 6 days ago |
|
|
aced21ab43 | 6 days ago |
|
|
5e43b0f203 | 7 days ago |
|
|
c5edffa990 | 7 days ago |
|
|
e631309322 | 7 days ago |
|
|
0f51253b3a | 7 days ago |
|
|
31037858ca | 1 week ago |
|
|
45fb6def10 | 1 week ago |
|
|
76560a2418 | 1 week ago |
|
|
931af482dc | 1 week ago |
47 changed files with 19114 additions and 125 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,101 @@
|
||||
# OTB Billing Database Reset / Rebuild Reference |
||||
|
||||
This is the current clean rebuild process for the otb_billing database. |
||||
|
||||
Important notes: |
||||
- Base schema is loaded from sql/schema_v0.0.2.sql |
||||
- Some tables are auto-created by the app at runtime |
||||
- Aging report does NOT require its own table |
||||
|
||||
Runtime-created tables: |
||||
- app_settings |
||||
- subscriptions |
||||
- email_log |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Step 1 — Optional SQL backup |
||||
|
||||
cd /home/def/otb_billing || exit 1 |
||||
|
||||
mysqldump -u otb_billing -p'!2Eas678' otb_billing > test-backup-before-reset.sql |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Step 2 — Drop and recreate the database |
||||
|
||||
cd /home/def/otb_billing || exit 1 |
||||
|
||||
sudo mysql <<'SQL' |
||||
DROP DATABASE IF EXISTS otb_billing; |
||||
|
||||
CREATE DATABASE otb_billing |
||||
CHARACTER SET utf8mb4 |
||||
COLLATE utf8mb4_unicode_ci; |
||||
|
||||
CREATE USER IF NOT EXISTS 'otb_billing'@'localhost' |
||||
IDENTIFIED BY '!2Eas678'; |
||||
|
||||
ALTER USER 'otb_billing'@'localhost' |
||||
IDENTIFIED BY '!2Eas678'; |
||||
|
||||
GRANT ALL PRIVILEGES ON otb_billing.* TO 'otb_billing'@'localhost'; |
||||
|
||||
FLUSH PRIVILEGES; |
||||
SQL |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Step 3 — Reload base schema |
||||
|
||||
cd /home/def/otb_billing || exit 1 |
||||
|
||||
mysql -u otb_billing -p'!2Eas678' otb_billing < sql/schema_v0.0.2.sql |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Step 4 — Start the app |
||||
|
||||
cd /home/def/otb_billing || exit 1 |
||||
|
||||
./run_dev.sh |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Step 5 — Trigger runtime-created tables |
||||
|
||||
Open these pages once: |
||||
|
||||
/settings |
||||
/subscriptions |
||||
|
||||
To create email_log send one test email. |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Step 6 — Verify rebuild worked |
||||
|
||||
cd /home/def/otb_billing || exit 1 |
||||
|
||||
mysql -u otb_billing -p'!2Eas678' -D otb_billing -e " |
||||
SHOW TABLES; |
||||
|
||||
SELECT COUNT(*) AS clients FROM clients; |
||||
SELECT COUNT(*) AS invoices FROM invoices; |
||||
SELECT COUNT(*) AS payments FROM payments; |
||||
SELECT COUNT(*) AS services FROM services; |
||||
SELECT COUNT(*) AS credit_ledger FROM credit_ledger; |
||||
" |
||||
|
||||
------------------------------------------------------------ |
||||
|
||||
Expected key tables |
||||
|
||||
clients |
||||
services |
||||
invoices |
||||
payments |
||||
credit_ledger |
||||
app_settings |
||||
subscriptions |
||||
email_log |
||||
@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
|
||||
import sys |
||||
import os |
||||
from datetime import datetime, timedelta |
||||
from dotenv import load_dotenv |
||||
|
||||
# load same environment config as Flask |
||||
load_dotenv("/home/def/otb_billing/.env") |
||||
|
||||
sys.path.append("/home/def/otb_billing/backend") |
||||
|
||||
from app import get_db_connection, send_configured_email, recalc_invoice_totals |
||||
|
||||
|
||||
REMINDER_DAYS = 7 |
||||
OVERDUE_DAYS = 14 |
||||
|
||||
|
||||
def main(): |
||||
print(f"[{datetime.now().isoformat()}] invoice_reminder_worker starting") |
||||
checked_count = 0 |
||||
reminder_sent_count = 0 |
||||
overdue_sent_count = 0 |
||||
skipped_count = 0 |
||||
conn = get_db_connection() |
||||
cursor = conn.cursor(dictionary=True) |
||||
|
||||
now = datetime.utcnow() |
||||
|
||||
cursor.execute(""" |
||||
SELECT |
||||
i.id, |
||||
i.invoice_number, |
||||
i.created_at, |
||||
i.client_id, |
||||
c.email, |
||||
c.company_name, |
||||
c.contact_name |
||||
FROM invoices i |
||||
JOIN clients c ON c.id = i.client_id |
||||
WHERE i.status IN ('pending','sent') |
||||
""") |
||||
|
||||
invoices = cursor.fetchall() |
||||
|
||||
for inv in invoices: |
||||
age = (now - inv["created_at"]).days |
||||
|
||||
email = inv["email"] |
||||
if not email: |
||||
continue |
||||
|
||||
name = inv.get("contact_name") or inv.get("company_name") or "Client" |
||||
|
||||
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}" |
||||
|
||||
if age >= OVERDUE_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} is overdue" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
Invoice {inv['invoice_number']} is now overdue. |
||||
|
||||
Amount Due: |
||||
{recalc_invoice_totals(inv['id'])['total']} |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Please arrange payment at your earliest convenience. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_overdue", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
elif age >= REMINDER_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} reminder" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
This is a reminder that invoice {inv['invoice_number']} is still outstanding. |
||||
|
||||
Amount Due: |
||||
{recalc_invoice_totals(inv['id'])['total']} |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Thank you. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_reminder", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
conn.close() |
||||
|
||||
|
||||
print(f"[{datetime.now().isoformat()}] checked={checked_count} reminders_sent={reminder_sent_count} overdue_sent={overdue_sent_count} skipped={skipped_count}") |
||||
|
||||
if __name__ == "__main__": |
||||
main() |
||||
@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
import sys |
||||
from datetime import datetime, timedelta |
||||
|
||||
sys.path.append("/home/def/otb_billing/backend") |
||||
|
||||
from app import get_db_connection, send_configured_email |
||||
|
||||
REMINDER_DAYS = 7 |
||||
OVERDUE_DAYS = 14 |
||||
|
||||
|
||||
def main(): |
||||
conn = get_db_connection() |
||||
cursor = conn.cursor(dictionary=True) |
||||
|
||||
now = datetime.utcnow() |
||||
|
||||
cursor.execute(""" |
||||
SELECT |
||||
i.id, |
||||
i.invoice_number, |
||||
i.created_at, |
||||
i.total, |
||||
i.client_id, |
||||
c.email, |
||||
c.company_name, |
||||
c.contact_name |
||||
FROM invoices i |
||||
JOIN clients c ON c.id = i.client_id |
||||
WHERE i.status IN ('pending','sent') |
||||
""") |
||||
|
||||
invoices = cursor.fetchall() |
||||
|
||||
for inv in invoices: |
||||
age = (now - inv["created_at"]).days |
||||
|
||||
email = inv["email"] |
||||
if not email: |
||||
continue |
||||
|
||||
name = inv.get("contact_name") or inv.get("company_name") or "Client" |
||||
|
||||
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}" |
||||
|
||||
if age >= OVERDUE_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} is overdue" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
Invoice {inv['invoice_number']} is now overdue. |
||||
|
||||
Amount Due: |
||||
{inv['total']} |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Please arrange payment at your earliest convenience. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_overdue", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
elif age >= REMINDER_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} reminder" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
This is a reminder that invoice {inv['invoice_number']} is still outstanding. |
||||
|
||||
Amount Due: |
||||
{inv['total']} |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Thank you. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_reminder", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
conn.close() |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
main() |
||||
@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
|
||||
import sys |
||||
import os |
||||
from datetime import datetime, timedelta |
||||
from dotenv import load_dotenv |
||||
|
||||
# load same environment config as Flask |
||||
load_dotenv("/home/def/otb_billing/.env") |
||||
|
||||
sys.path.append("/home/def/otb_billing/backend") |
||||
|
||||
from app import get_db_connection, send_configured_email |
||||
|
||||
|
||||
REMINDER_DAYS = 7 |
||||
OVERDUE_DAYS = 14 |
||||
|
||||
|
||||
def main(): |
||||
conn = get_db_connection() |
||||
cursor = conn.cursor(dictionary=True) |
||||
|
||||
now = datetime.utcnow() |
||||
|
||||
cursor.execute(""" |
||||
SELECT |
||||
i.id, |
||||
i.invoice_number, |
||||
i.created_at, |
||||
i.total, |
||||
i.client_id, |
||||
c.email, |
||||
c.company_name, |
||||
c.contact_name |
||||
FROM invoices i |
||||
JOIN clients c ON c.id = i.client_id |
||||
WHERE i.status IN ('pending','sent') |
||||
""") |
||||
|
||||
invoices = cursor.fetchall() |
||||
|
||||
for inv in invoices: |
||||
age = (now - inv["created_at"]).days |
||||
|
||||
email = inv["email"] |
||||
if not email: |
||||
continue |
||||
|
||||
name = inv.get("contact_name") or inv.get("company_name") or "Client" |
||||
|
||||
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}" |
||||
|
||||
if age >= OVERDUE_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} is overdue" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
Invoice {inv['invoice_number']} is now overdue. |
||||
|
||||
Amount Due: |
||||
{inv['total']} |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Please arrange payment at your earliest convenience. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_overdue", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
elif age >= REMINDER_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} reminder" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
This is a reminder that invoice {inv['invoice_number']} is still outstanding. |
||||
|
||||
Amount Due: |
||||
{inv['total']} |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Thank you. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_reminder", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
conn.close() |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
main() |
||||
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
|
||||
import sys |
||||
import os |
||||
from datetime import datetime, timedelta |
||||
from dotenv import load_dotenv |
||||
|
||||
# load same environment config as Flask |
||||
load_dotenv("/home/def/otb_billing/.env") |
||||
|
||||
sys.path.append("/home/def/otb_billing/backend") |
||||
|
||||
from app import get_db_connection, send_configured_email |
||||
|
||||
|
||||
REMINDER_DAYS = 7 |
||||
OVERDUE_DAYS = 14 |
||||
|
||||
|
||||
def main(): |
||||
conn = get_db_connection() |
||||
cursor = conn.cursor(dictionary=True) |
||||
|
||||
now = datetime.utcnow() |
||||
|
||||
cursor.execute(""" |
||||
SELECT |
||||
i.id, |
||||
i.invoice_number, |
||||
i.created_at, |
||||
i.client_id, |
||||
c.email, |
||||
c.company_name, |
||||
c.contact_name |
||||
FROM invoices i |
||||
JOIN clients c ON c.id = i.client_id |
||||
WHERE i.status IN ('pending','sent') |
||||
""") |
||||
|
||||
invoices = cursor.fetchall() |
||||
|
||||
for inv in invoices: |
||||
age = (now - inv["created_at"]).days |
||||
|
||||
email = inv["email"] |
||||
if not email: |
||||
continue |
||||
|
||||
name = inv.get("contact_name") or inv.get("company_name") or "Client" |
||||
|
||||
portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}" |
||||
|
||||
if age >= OVERDUE_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} is overdue" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
Invoice {inv['invoice_number']} is now overdue. |
||||
|
||||
Amount Due: |
||||
Invoice amount available in portal |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Please arrange payment at your earliest convenience. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_overdue", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
elif age >= REMINDER_DAYS: |
||||
|
||||
subject = f"Invoice {inv['invoice_number']} reminder" |
||||
|
||||
body = f""" |
||||
Hello {name}, |
||||
|
||||
This is a reminder that invoice {inv['invoice_number']} is still outstanding. |
||||
|
||||
Amount Due: |
||||
Invoice amount available in portal |
||||
|
||||
View invoice: |
||||
{portal_url} |
||||
|
||||
Thank you. |
||||
|
||||
OutsideTheBox |
||||
""" |
||||
|
||||
send_configured_email( |
||||
to_email=email, |
||||
subject=subject, |
||||
body=body, |
||||
attachments=None, |
||||
email_type="invoice_reminder", |
||||
invoice_id=inv["id"] |
||||
) |
||||
|
||||
conn.close() |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
main() |
||||
@ -0,0 +1,256 @@
|
||||
<!doctype html> |
||||
<html> |
||||
<head> |
||||
<title>Invoice {{ invoice.invoice_number }}</title> |
||||
<style> |
||||
body { |
||||
font-family: Arial, sans-serif; |
||||
margin: 30px; |
||||
color: #111; |
||||
} |
||||
.top-links { |
||||
margin-bottom: 20px; |
||||
} |
||||
.top-links a { |
||||
margin-right: 15px; |
||||
} |
||||
.invoice-wrap { |
||||
max-width: 900px; |
||||
} |
||||
.header-row { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: flex-start; |
||||
margin-bottom: 25px; |
||||
} |
||||
.title-box h1 { |
||||
margin: 0 0 8px 0; |
||||
} |
||||
.status-badge { |
||||
display: inline-block; |
||||
padding: 4px 10px; |
||||
border-radius: 999px; |
||||
font-size: 12px; |
||||
font-weight: bold; |
||||
text-transform: uppercase; |
||||
} |
||||
.status-draft { background: #e5e7eb; color: #111827; } |
||||
.status-pending { background: #dbeafe; color: #1d4ed8; } |
||||
.status-partial { background: #fef3c7; color: #92400e; } |
||||
.status-paid { background: #dcfce7; color: #166534; } |
||||
.status-overdue { background: #fee2e2; color: #991b1b; } |
||||
.status-cancelled { background: #e5e7eb; color: #4b5563; } |
||||
|
||||
.info-grid { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
gap: 30px; |
||||
margin-bottom: 25px; |
||||
} |
||||
.info-card { |
||||
border: 1px solid #ccc; |
||||
padding: 15px; |
||||
} |
||||
.info-card h3 { |
||||
margin-top: 0; |
||||
} |
||||
.summary-table, |
||||
.total-table { |
||||
width: 100%; |
||||
border-collapse: collapse; |
||||
margin-bottom: 25px; |
||||
} |
||||
.summary-table th, |
||||
.summary-table td, |
||||
.total-table th, |
||||
.total-table td { |
||||
border: 1px solid #ccc; |
||||
padding: 10px; |
||||
text-align: left; |
||||
} |
||||
.total-table { |
||||
max-width: 420px; |
||||
margin-left: auto; |
||||
} |
||||
.notes-box { |
||||
border: 1px solid #ccc; |
||||
padding: 15px; |
||||
white-space: pre-wrap; |
||||
margin-top: 20px; |
||||
} |
||||
.print-only { |
||||
display: none; |
||||
} |
||||
@media print { |
||||
.top-links, |
||||
.screen-only, |
||||
footer { |
||||
display: none !important; |
||||
} |
||||
.print-only { |
||||
display: block; |
||||
} |
||||
body { |
||||
margin: 0; |
||||
} |
||||
} |
||||
</style> |
||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||
</head> |
||||
<body> |
||||
|
||||
<div class="invoice-wrap"> |
||||
{% if request.args.get('email_sent') == '1' %} |
||||
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;"> |
||||
Invoice email sent successfully. |
||||
</div> |
||||
{% endif %} |
||||
{% if request.args.get('email_failed') == '1' %} |
||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;"> |
||||
Invoice email failed. Check SMTP settings or server log. |
||||
</div> |
||||
{% endif %} |
||||
<div class="top-links screen-only"> |
||||
<a href="/">Home</a> |
||||
<a href="/invoices">Back to Invoices</a> |
||||
<a href="/invoices/edit/{{ invoice.id }}">Edit Invoice</a> |
||||
<a href="#" onclick="window.print(); return false;">Print</a> |
||||
<a href="/invoices/pdf/{{ invoice.id }}">PDF</a> |
||||
{% if invoice.email %} |
||||
<form method="post" action="/invoices/email/{{ invoice.id }}" style="display:inline;"> |
||||
<button type="submit">Send Email</button> |
||||
</form> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<div class="header-row"> |
||||
|
||||
{% if settings.business_logo_url %} |
||||
<img src="{{ settings.business_logo_url }}" style="height:70px;margin-bottom:10px;"> |
||||
{% endif %} |
||||
|
||||
<div class="title-box"> |
||||
<h1>Invoice {{ invoice.invoice_number }}</h1> |
||||
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span> |
||||
</div> |
||||
<div style="text-align:right;"> |
||||
<strong>{{ settings.business_name or 'OTB Billing' }}</strong><br> |
||||
{{ settings.business_tagline or '' }}<br> |
||||
{% if settings.business_address %}{{ settings.business_address }}<br>{% endif %} |
||||
{% if settings.business_email %}{{ settings.business_email }}<br>{% endif %} |
||||
{% if settings.business_phone %}{{ settings.business_phone }}<br>{% endif %} |
||||
{% if settings.business_website %}{{ settings.business_website }}{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="info-grid"> |
||||
<div class="info-card"> |
||||
<h3>Bill To</h3> |
||||
<strong>{{ invoice.company_name }}</strong><br> |
||||
{% if invoice.contact_name %}{{ invoice.contact_name }}<br>{% endif %} |
||||
{% if invoice.email %}{{ invoice.email }}<br>{% endif %} |
||||
{% if invoice.phone %}{{ invoice.phone }}<br>{% endif %} |
||||
Client Code: {{ invoice.client_code }} |
||||
</div> |
||||
|
||||
<div class="info-card"> |
||||
<h3>Invoice Details</h3> |
||||
Invoice #: {{ invoice.invoice_number }}<br> |
||||
Issued: {{ invoice.issued_at|localtime }}<br> |
||||
Due: {{ invoice.due_at|localtime }}<br> |
||||
{% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}<br>{% endif %} |
||||
Currency: {{ invoice.currency_code }}<br> |
||||
{% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}<br>{% endif %} |
||||
{% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
<table class="summary-table"> |
||||
<tr> |
||||
<th>Service Code</th> |
||||
<th>Service</th> |
||||
<th>Description</th> |
||||
<th>Total</th> |
||||
</tr> |
||||
<tr> |
||||
<td>{{ invoice.service_code or '-' }}</td> |
||||
<td>{{ invoice.service_name or '-' }}</td> |
||||
<td>{{ invoice.notes or '-' }}</td> |
||||
<td>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
||||
</tr> |
||||
</table> |
||||
|
||||
<table class="total-table"> |
||||
<tr> |
||||
<th>Subtotal</th> |
||||
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
||||
</tr> |
||||
<tr> |
||||
<th>{{ settings.tax_label or 'Tax' }}</th> |
||||
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
||||
</tr> |
||||
<tr> |
||||
<th>Total</th> |
||||
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td> |
||||
</tr> |
||||
<tr> |
||||
<th>Paid</th> |
||||
<td>{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
||||
</tr> |
||||
<tr> |
||||
<th>Remaining</th> |
||||
<td>{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
||||
</tr> |
||||
</table> |
||||
|
||||
{% if latest_email_log %} |
||||
<div class="notes-box"> |
||||
<strong>Latest Email Activity</strong><br><br> |
||||
Status: {{ latest_email_log.status }}<br> |
||||
Recipient: {{ latest_email_log.recipient_email }}<br> |
||||
Subject: {{ latest_email_log.subject }}<br> |
||||
Sent At: {{ latest_email_log.sent_at|localtime }}<br> |
||||
{% if latest_email_log.error_message %} |
||||
Error: {{ latest_email_log.error_message }} |
||||
{% endif %} |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if settings.payment_terms %} |
||||
<div class="notes-box"> |
||||
<strong>Payment Terms</strong><br><br> |
||||
{{ settings.payment_terms }} |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if settings.invoice_footer %} |
||||
<div class="notes-box"> |
||||
<strong>Footer</strong><br><br> |
||||
{{ settings.invoice_footer }} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
{% include "footer.html" %} |
||||
|
||||
<hr> |
||||
|
||||
<h3>Payment Instructions</h3> |
||||
|
||||
<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> |
||||
|
||||
<p><strong>Credit Card (Square)</strong><br> |
||||
Contact us for a secure Square payment link.</p> |
||||
|
||||
<p> |
||||
If you have questions please contact |
||||
<a href="mailto:support@outsidethebox.top">support@outsidethebox.top</a> |
||||
</p> |
||||
|
||||
|
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,162 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Client Dashboard - 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; |
||||
} |
||||
.summary-grid { |
||||
display:grid; |
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||
gap:1rem; |
||||
margin: 1rem 0 1.25rem 0; |
||||
} |
||||
.summary-card { |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
border-radius: 14px; |
||||
padding: 1rem; |
||||
background: rgba(255,255,255,0.03); |
||||
} |
||||
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||
table.portal-table { |
||||
width: 100%; |
||||
border-collapse: collapse; |
||||
} |
||||
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; |
||||
} |
||||
.invoice-link { |
||||
color: inherit; |
||||
text-decoration: underline; |
||||
font-weight: 600; |
||||
} |
||||
.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; |
||||
} |
||||
</style> |
||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||
</head> |
||||
<body> |
||||
<div class="portal-wrap"> |
||||
<div class="portal-top"> |
||||
<div> |
||||
<h1>Client Dashboard</h1> |
||||
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||
</div> |
||||
<div class="portal-actions"> |
||||
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</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> |
||||
|
||||
<div class="summary-grid"> |
||||
<div class="summary-card"> |
||||
<h3>Total Invoices</h3> |
||||
<div>{{ invoice_count }}</div> |
||||
</div> |
||||
<div class="summary-card"> |
||||
<h3>Total Outstanding</h3> |
||||
<div>{{ total_outstanding }}</div> |
||||
</div> |
||||
<div class="summary-card"> |
||||
<h3>Total Paid</h3> |
||||
<div>{{ total_paid }}</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<h2>Invoices</h2> |
||||
<table class="portal-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Invoice</th> |
||||
<th>Status</th> |
||||
<th>Created</th> |
||||
<th>Total</th> |
||||
<th>Paid</th> |
||||
<th>Outstanding</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for row in invoices %} |
||||
<tr> |
||||
<td> |
||||
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||
</a> |
||||
</td> |
||||
<td> |
||||
{% set s = (row.status or "")|lower %} |
||||
{% if s == "paid" %} |
||||
<span class="status-badge status-paid">{{ row.status }}</span> |
||||
{% elif s == "pending" %} |
||||
<span class="status-badge status-pending">{{ row.status }}</span> |
||||
{% elif s == "overdue" %} |
||||
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||
{% else %} |
||||
<span class="status-badge status-other">{{ row.status }}</span> |
||||
{% endif %} |
||||
</td> |
||||
<td>{{ row.created_at }}</td> |
||||
<td>{{ row.total_amount }}</td> |
||||
<td>{{ row.amount_paid }}</td> |
||||
<td>{{ row.outstanding }}</td> |
||||
</tr> |
||||
{% else %} |
||||
<tr> |
||||
<td colspan="6">No invoices available.</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
|
||||
|
||||
<script> |
||||
(function() { |
||||
setTimeout(function() { |
||||
window.location.reload(); |
||||
}, 20000); |
||||
})(); |
||||
</script> |
||||
|
||||
{% include "footer.html" %} |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Forgot Portal Password - OutsideTheBox</title> |
||||
<link rel="stylesheet" href="/static/css/style.css"> |
||||
<style> |
||||
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||
.portal-card { |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
border-radius: 16px; |
||||
padding: 1.4rem; |
||||
background: rgba(255,255,255,0.03); |
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||
} |
||||
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||
.portal-form { display: grid; gap: 0.9rem; } |
||||
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||
.portal-form input { |
||||
width: 100%; |
||||
padding: 0.8rem 0.9rem; |
||||
border-radius: 10px; |
||||
border: 1px solid rgba(255,255,255,0.18); |
||||
background: rgba(255,255,255,0.05); |
||||
color: inherit; |
||||
box-sizing: border-box; |
||||
} |
||||
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||
.portal-btn { |
||||
display: inline-block; |
||||
padding: 0.8rem 1rem; |
||||
border-radius: 10px; |
||||
text-decoration: none; |
||||
border: 1px solid rgba(255,255,255,0.18); |
||||
background: rgba(255,255,255,0.06); |
||||
color: inherit; |
||||
cursor: pointer; |
||||
} |
||||
.portal-msg { |
||||
margin-bottom: 1rem; |
||||
padding: 0.85rem 1rem; |
||||
border-radius: 10px; |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
background: rgba(255,255,255,0.04); |
||||
} |
||||
</style> |
||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||
</head> |
||||
<body> |
||||
<div class="portal-wrap"> |
||||
<div class="portal-card"> |
||||
<h1>Reset Portal Password</h1> |
||||
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||
|
||||
{% if error %} |
||||
<div class="portal-msg">{{ error }}</div> |
||||
{% endif %} |
||||
|
||||
{% if message %} |
||||
<div class="portal-msg">{{ message }}</div> |
||||
{% endif %} |
||||
|
||||
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||
<div> |
||||
<label for="email">Email Address</label> |
||||
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||
</div> |
||||
|
||||
<div class="portal-actions"> |
||||
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
|
||||
{% include "footer.html" %} |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,727 @@
|
||||
<!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-wallet { background:#2563eb; } |
||||
.pay-btn-mobile { background:#7c3aed; } |
||||
.pay-btn-copy { background:#374151; } |
||||
|
||||
.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; } |
||||
.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; } |
||||
|
||||
.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; } |
||||
|
||||
.wallet-actions { |
||||
display:flex; |
||||
gap:0.75rem; |
||||
flex-wrap:wrap; |
||||
margin-top:0.9rem; |
||||
align-items:center; |
||||
} |
||||
.wallet-help { |
||||
margin-top: 0.85rem; |
||||
padding: 0.9rem 1rem; |
||||
border-radius: 10px; |
||||
background: rgba(255,255,255,0.04); |
||||
border: 1px solid rgba(255,255,255,0.10); |
||||
} |
||||
.wallet-help h4 { |
||||
margin: 0 0 0.55rem 0; |
||||
font-size: 1rem; |
||||
} |
||||
.wallet-help p { |
||||
margin: 0.35rem 0; |
||||
} |
||||
.wallet-note { opacity:0.9; margin-top:0.65rem; } |
||||
.mono { font-family: monospace; } |
||||
.copy-row { |
||||
display:flex; |
||||
gap:0.5rem; |
||||
flex-wrap:wrap; |
||||
align-items:center; |
||||
margin-top:0.65rem; |
||||
} |
||||
.copy-target { |
||||
flex: 1 1 420px; |
||||
min-width: 220px; |
||||
} |
||||
.copy-status { |
||||
display:inline-block; |
||||
margin-left: 0.5rem; |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
@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 class="success-box">✓ 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 pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} |
||||
<span class="status-badge status-pending">processing</span> |
||||
{% elif 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>Your quote is protected 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 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">Watching transaction / waiting for confirmation</div> |
||||
</div> |
||||
{% elif 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">Quote protected while you open wallet</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 or pending_crypto_payment.processing_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 id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> |
||||
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
||||
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> |
||||
|
||||
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} |
||||
<div class="wallet-actions"> |
||||
<button |
||||
type="button" |
||||
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 '' }}" |
||||
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' |
||||
> |
||||
Open MetaMask / Rabby |
||||
</button> |
||||
|
||||
<a |
||||
id="metamaskMobileLink" |
||||
href="#" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="pay-btn pay-btn-mobile" |
||||
data-invoice-id="{{ invoice.id }}" |
||||
> |
||||
Open in MetaMask Mobile |
||||
</a> |
||||
|
||||
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> |
||||
Copy Payment Details |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="wallet-help"> |
||||
<h4>Fastest way to pay</h4> |
||||
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> |
||||
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> |
||||
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> |
||||
</div> |
||||
|
||||
<div class="wallet-note"> |
||||
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. |
||||
</div> |
||||
|
||||
<div class="copy-row"> |
||||
<span id="walletStatusText"></span> |
||||
<span id="copyStatusText" class="copy-status"></span> |
||||
</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. Watching transaction / waiting for 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> |
||||
</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 %} |
||||
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</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 invoice_payments %} |
||||
<div class="detail-card" style="margin-top:1.25rem;"> |
||||
<h3>Payments Applied</h3> |
||||
<table class="portal-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Method</th> |
||||
<th>Amount</th> |
||||
<th>Status</th> |
||||
<th>Received</th> |
||||
<th>Reference / TXID</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for p in invoice_payments %} |
||||
<tr> |
||||
<td>{{ p.payment_method_label }}</td> |
||||
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> |
||||
<td>{{ p.payment_status }}</td> |
||||
<td>{{ p.received_at_local }}</td> |
||||
<td> |
||||
{% if p.txid %} |
||||
{{ p.txid }} |
||||
{% elif p.reference %} |
||||
{{ p.reference }} |
||||
{% else %} |
||||
- |
||||
{% endif %} |
||||
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
</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, disableSelector) { |
||||
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"); |
||||
} |
||||
if (disableSelector) { |
||||
document.querySelectorAll(disableSelector).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", "#cryptoPickForm button"); |
||||
} |
||||
|
||||
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", "#walletPayButton"); |
||||
} |
||||
|
||||
const processingTimer = document.getElementById("processingTimerValue"); |
||||
if (processingTimer && processingTimer.dataset.expiry) { |
||||
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.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, chainAddParams) { |
||||
const hexChainId = "0x" + Number(chainId).toString(16); |
||||
try { |
||||
await window.ethereum.request({ |
||||
method: "wallet_switchEthereumChain", |
||||
params: [{ chainId: hexChainId }] |
||||
}); |
||||
return; |
||||
} catch (err) { |
||||
const code = err && (err.code ?? err?.data?.originalError?.code); |
||||
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { |
||||
await window.ethereum.request({ |
||||
method: "wallet_addEthereumChain", |
||||
params: [chainAddParams] |
||||
}); |
||||
await window.ethereum.request({ |
||||
method: "wallet_switchEthereumChain", |
||||
params: [{ chainId: hexChainId }] |
||||
}); |
||||
return; |
||||
} |
||||
throw err; |
||||
} |
||||
} |
||||
|
||||
function buildMetaMaskMobileLink() { |
||||
const currentUrl = window.location.href; |
||||
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); |
||||
} |
||||
|
||||
const mmLink = document.getElementById("metamaskMobileLink"); |
||||
if (mmLink) { |
||||
mmLink.href = buildMetaMaskMobileLink(); |
||||
} |
||||
|
||||
async function copyText(text) { |
||||
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||
await navigator.clipboard.writeText(text); |
||||
return; |
||||
} |
||||
const ta = document.createElement("textarea"); |
||||
ta.value = text; |
||||
document.body.appendChild(ta); |
||||
ta.select(); |
||||
document.execCommand("copy"); |
||||
ta.remove(); |
||||
} |
||||
|
||||
const copyBtn = document.getElementById("copyDetailsButton"); |
||||
if (copyBtn) { |
||||
copyBtn.addEventListener("click", async function() { |
||||
const copyStatus = document.getElementById("copyStatusText"); |
||||
const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; |
||||
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; |
||||
const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; |
||||
const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; |
||||
|
||||
const payload = |
||||
`Asset: ${asset} |
||||
Amount: ${amount} ${asset} |
||||
Wallet: ${walletAddress} |
||||
Reference: ${invoiceRef}`; |
||||
|
||||
try { |
||||
await copyText(payload); |
||||
if (copyStatus) copyStatus.textContent = "Payment details copied."; |
||||
} catch (err) { |
||||
if (copyStatus) copyStatus.textContent = "Copy failed."; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
const walletButton = document.getElementById("walletPayButton"); |
||||
|
||||
function pendingTxStorageKey(invoiceId, paymentId) { |
||||
return `otb_pending_tx_${invoiceId}_${paymentId}`; |
||||
} |
||||
|
||||
async function submitTxHash(invoiceId, paymentId, asset, txHash) { |
||||
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { |
||||
method: "POST", |
||||
headers: { |
||||
"Content-Type": "application/json", |
||||
"Accept": "application/json" |
||||
}, |
||||
body: JSON.stringify({ |
||||
payment_id: paymentId, |
||||
asset: asset, |
||||
tx_hash: txHash |
||||
}) |
||||
}); |
||||
|
||||
let data = {}; |
||||
try { |
||||
data = await res.json(); |
||||
} catch (err) { |
||||
data = { ok: false, error: "invalid_json_response" }; |
||||
} |
||||
|
||||
if (!res.ok || !data.ok) { |
||||
throw new Error(data.error || `submit_failed_http_${res.status}`); |
||||
} |
||||
|
||||
return data; |
||||
} |
||||
|
||||
async function tryRecoverPendingTxFromStorage() { |
||||
const invoiceId = "{{ invoice.id }}"; |
||||
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; |
||||
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; |
||||
|
||||
if (!invoiceId || !paymentId || !asset) return; |
||||
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||
return; |
||||
{% endif %} |
||||
|
||||
const key = pendingTxStorageKey(invoiceId, paymentId); |
||||
const savedTx = localStorage.getItem(key); |
||||
if (!savedTx || !savedTx.startsWith("0x")) return; |
||||
|
||||
const walletStatus = document.getElementById("walletStatusText"); |
||||
try { |
||||
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; |
||||
await submitTxHash(invoiceId, paymentId, asset, savedTx); |
||||
localStorage.removeItem(key); |
||||
const url = new URL(window.location.href); |
||||
url.searchParams.set("pay", "crypto"); |
||||
url.searchParams.set("asset", asset); |
||||
url.searchParams.set("payment_id", paymentId); |
||||
window.location.href = url.toString(); |
||||
} catch (err) { |
||||
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; |
||||
} |
||||
} |
||||
|
||||
if (walletButton) { |
||||
walletButton.addEventListener("click", async function() { |
||||
const walletStatus = document.getElementById("walletStatusText"); |
||||
const invoiceId = this.dataset.invoiceId; |
||||
const paymentId = this.dataset.paymentId; |
||||
const asset = this.dataset.asset; |
||||
const chainId = this.dataset.chainId; |
||||
const assetType = this.dataset.assetType; |
||||
const to = this.dataset.to; |
||||
const amount = this.dataset.amount; |
||||
const decimals = Number(this.dataset.decimals || "18"); |
||||
const tokenContract = this.dataset.tokenContract || ""; |
||||
let chainAddParams = null; |
||||
try { |
||||
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; |
||||
} catch (err) { |
||||
chainAddParams = null; |
||||
} |
||||
|
||||
const setStatus = (msg) => { |
||||
if (walletStatus) walletStatus.textContent = msg; |
||||
}; |
||||
|
||||
if (!window.ethereum || !window.ethereum.request) { |
||||
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
this.disabled = true; |
||||
setStatus("Opening wallet..."); |
||||
|
||||
await window.ethereum.request({ method: "eth_requestAccounts" }); |
||||
|
||||
if (chainId && chainId !== "None" && chainId !== "") { |
||||
try { |
||||
await switchChain(Number(chainId), chainAddParams); |
||||
} catch (err) { |
||||
setStatus(`Chain switch failed: ${err.message || err}`); |
||||
this.disabled = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
let txParams; |
||||
if (assetType === "token" && tokenContract) { |
||||
txParams = { |
||||
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||
to: tokenContract, |
||||
data: erc20TransferData(to, amount, decimals), |
||||
value: "0x0" |
||||
}; |
||||
} else { |
||||
txParams = { |
||||
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||
to: to, |
||||
value: toHexBigIntFromDecimal(amount, decimals) |
||||
}; |
||||
} |
||||
|
||||
setStatus("Waiting for wallet confirmation..."); |
||||
const txHash = await window.ethereum.request({ |
||||
method: "eth_sendTransaction", |
||||
params: [txParams] |
||||
}); |
||||
|
||||
if (!txHash || !String(txHash).startsWith("0x")) { |
||||
throw new Error("wallet did not return a tx hash"); |
||||
} |
||||
|
||||
const storageKey = pendingTxStorageKey(invoiceId, paymentId); |
||||
localStorage.setItem(storageKey, String(txHash)); |
||||
|
||||
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); |
||||
|
||||
await submitTxHash(invoiceId, paymentId, asset, txHash); |
||||
|
||||
localStorage.removeItem(storageKey); |
||||
|
||||
setStatus("Transaction submitted. Reloading into processing view..."); |
||||
const url = new URL(window.location.href); |
||||
url.searchParams.set("pay", "crypto"); |
||||
url.searchParams.set("asset", asset); |
||||
url.searchParams.set("payment_id", paymentId); |
||||
window.location.href = url.toString(); |
||||
} catch (err) { |
||||
setStatus(`Wallet submit failed: ${err.message || err}`); |
||||
this.disabled = false; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
tryRecoverPendingTxFromStorage(); |
||||
})(); |
||||
</script> |
||||
|
||||
|
||||
<script> |
||||
(function() { |
||||
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; |
||||
if (processingAutoRefreshEnabled) { |
||||
setTimeout(function() { |
||||
window.location.reload(); |
||||
}, 10000); |
||||
} |
||||
})(); |
||||
</script> |
||||
|
||||
{% include "footer.html" %} |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,209 @@
|
||||
<!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); |
||||
} |
||||
.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; |
||||
} |
||||
.invoice-actions { |
||||
margin-top: 1rem; |
||||
} |
||||
.invoice-actions a { |
||||
margin-right: 1rem; |
||||
text-decoration: underline; |
||||
} |
||||
.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; |
||||
} |
||||
</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> |
||||
|
||||
<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 pdf_url %} |
||||
<div class="invoice-actions"> |
||||
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
{% include "footer.html" %} |
||||
|
||||
<hr> |
||||
|
||||
<h3>Payment Instructions</h3> |
||||
|
||||
<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> |
||||
|
||||
<p><strong>Credit Card (Square)</strong><br> |
||||
|
||||
<br><br> |
||||
|
||||
<p><strong>Credit Card (Square)</strong></p> |
||||
|
||||
<a href="https://square.link/u/H0cimZku" target="_blank" |
||||
style=" |
||||
display:inline-block; |
||||
padding:12px 18px; |
||||
background:#28a745; |
||||
color:white; |
||||
text-decoration:none; |
||||
border-radius:6px; |
||||
font-weight:bold; |
||||
"> |
||||
Pay with Card (Square) |
||||
</a> |
||||
|
||||
<p style="font-size:12px;color:#666;margin-top:6px;"> |
||||
You will be redirected to Square's secure payment page. |
||||
Please include your invoice number in the payment note. |
||||
</p> |
||||
</p> |
||||
|
||||
<p> |
||||
If you have questions please contact |
||||
<a href="mailto:support@outsidethebox.top">support@outsidethebox.top</a> |
||||
</p> |
||||
|
||||
|
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,187 @@
|
||||
<!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); |
||||
} |
||||
.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; |
||||
} |
||||
.invoice-actions { |
||||
margin-top: 1rem; |
||||
} |
||||
.invoice-actions a { |
||||
margin-right: 1rem; |
||||
text-decoration: underline; |
||||
} |
||||
.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; |
||||
} |
||||
</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> |
||||
|
||||
<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 pdf_url %} |
||||
<div class="invoice-actions"> |
||||
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
{% include "footer.html" %} |
||||
|
||||
<hr> |
||||
|
||||
<h3>Payment Instructions</h3> |
||||
|
||||
<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> |
||||
|
||||
<p><strong>Credit Card (Square)</strong><br> |
||||
Contact us for a secure Square payment link.</p> |
||||
|
||||
<p> |
||||
If you have questions please contact |
||||
<a href="mailto:support@outsidethebox.top">support@outsidethebox.top</a> |
||||
</p> |
||||
|
||||
|
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Client Portal - OutsideTheBox</title> |
||||
<link rel="stylesheet" href="/static/css/style.css"> |
||||
<style> |
||||
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||
.portal-card { |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
border-radius: 16px; |
||||
padding: 1.4rem; |
||||
background: rgba(255,255,255,0.03); |
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||
} |
||||
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||
.portal-form { display: grid; gap: 0.9rem; } |
||||
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||
.portal-form input { |
||||
width: 100%; |
||||
padding: 0.8rem 0.9rem; |
||||
border-radius: 10px; |
||||
border: 1px solid rgba(255,255,255,0.18); |
||||
background: rgba(255,255,255,0.05); |
||||
color: inherit; |
||||
box-sizing: border-box; |
||||
} |
||||
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||
.portal-btn { |
||||
display: inline-block; |
||||
padding: 0.8rem 1rem; |
||||
border-radius: 10px; |
||||
text-decoration: none; |
||||
border: 1px solid rgba(255,255,255,0.18); |
||||
background: rgba(255,255,255,0.06); |
||||
color: inherit; |
||||
cursor: pointer; |
||||
} |
||||
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||
.portal-links { margin-top: 1rem; } |
||||
.portal-links a { margin-right: 1rem; } |
||||
.portal-msg { |
||||
margin-bottom: 1rem; |
||||
padding: 0.85rem 1rem; |
||||
border-radius: 10px; |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
background: rgba(255,255,255,0.04); |
||||
} |
||||
</style> |
||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||
</head> |
||||
<body> |
||||
<div class="portal-wrap"> |
||||
<div class="portal-card"> |
||||
<h1>OutsideTheBox Client Portal</h1> |
||||
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||
|
||||
{% if portal_message %} |
||||
<div class="portal-msg">{{ portal_message }}</div> |
||||
{% endif %} |
||||
|
||||
<form class="portal-form" method="post" action="/portal/login"> |
||||
<div> |
||||
<label for="email">Email Address</label> |
||||
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||
</div> |
||||
|
||||
<div> |
||||
<label for="credential">Access Code or Password</label> |
||||
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||
</div> |
||||
|
||||
<div class="portal-actions"> |
||||
<button class="portal-btn" type="submit">Sign In</button> |
||||
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||
</div> |
||||
</form> |
||||
|
||||
|
||||
<div style="margin-top:15px;"> |
||||
<a href="/portal/forgot-password">Forgot your password?</a> |
||||
</div> |
||||
|
||||
<p class="portal-note"> |
||||
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||
</p> |
||||
</div> |
||||
</div> |
||||
|
||||
{% include "footer.html" %} |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Set Portal Password - OutsideTheBox</title> |
||||
<link rel="stylesheet" href="/static/css/style.css"> |
||||
<style> |
||||
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||
.portal-card { |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
border-radius: 16px; |
||||
padding: 1.4rem; |
||||
background: rgba(255,255,255,0.03); |
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||
} |
||||
.portal-form { display: grid; gap: 0.9rem; } |
||||
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||
.portal-form input { |
||||
width: 100%; |
||||
padding: 0.8rem 0.9rem; |
||||
border-radius: 10px; |
||||
border: 1px solid rgba(255,255,255,0.18); |
||||
background: rgba(255,255,255,0.05); |
||||
color: inherit; |
||||
box-sizing: border-box; |
||||
} |
||||
.portal-btn { |
||||
display: inline-block; |
||||
padding: 0.8rem 1rem; |
||||
border-radius: 10px; |
||||
text-decoration: none; |
||||
border: 1px solid rgba(255,255,255,0.18); |
||||
background: rgba(255,255,255,0.06); |
||||
color: inherit; |
||||
cursor: pointer; |
||||
} |
||||
.portal-msg { |
||||
margin-bottom: 1rem; |
||||
padding: 0.85rem 1rem; |
||||
border-radius: 10px; |
||||
border: 1px solid rgba(255,255,255,0.16); |
||||
background: rgba(255,255,255,0.04); |
||||
} |
||||
</style> |
||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||
</head> |
||||
<body> |
||||
<div class="portal-wrap"> |
||||
<div class="portal-card"> |
||||
<h1>Create Your Portal Password</h1> |
||||
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||
|
||||
{% if portal_message %} |
||||
<div class="portal-msg">{{ portal_message }}</div> |
||||
{% endif %} |
||||
|
||||
<form class="portal-form" method="post" action="/portal/set-password"> |
||||
<div> |
||||
<label for="password">New Password</label> |
||||
<input id="password" name="password" type="password" required> |
||||
</div> |
||||
<div> |
||||
<label for="password2">Confirm Password</label> |
||||
<input id="password2" name="password2" type="password" required> |
||||
</div> |
||||
<div> |
||||
<button class="portal-btn" type="submit">Set Password</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
|
||||
{% include "footer.html" %} |
||||
</body> |
||||
</html> |
||||
Loading…
Reference in new issue