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