45 changed files with 16032 additions and 20 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,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,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> |
||||||
Loading…
Reference in new issue