Browse Source

v0.5.0 - Square auto-payment, reconciliation, accountbook, reminder timer

main v0.5.0
def 1 week ago
parent
commit
31037858ca
  1. 36
      PROJECT_STATE.md
  2. 33
      README.md
  3. 2
      VERSION
  4. 1347
      backend/app.py
  5. 3312
      backend/app.py.deduped_candidate
  6. 6503
      backend/app_cleanup_test.py
  7. 3312
      backend/app_deduped_test.py
  8. 101
      docs/db_reset_rebuild_reference.md
  9. 123
      scripts/invoice_reminder_worker.py
  10. 110
      scripts/invoice_reminder_worker.py.bak_20260313-035553
  11. 117
      scripts/invoice_reminder_worker.py.bak_20260313-035724
  12. 116
      scripts/invoice_reminder_worker.py.bak_20260313-041145
  13. 1
      templates/base.html
  14. 74
      templates/clients/edit.html
  15. 1
      templates/clients/list.html
  16. 1
      templates/clients/new.html
  17. 1
      templates/credits/add.html
  18. 1
      templates/credits/list.html
  19. 3
      templates/dashboard.html
  20. 3
      templates/health.html
  21. 1
      templates/invoices/edit.html
  22. 1
      templates/invoices/list.html
  23. 1
      templates/invoices/new.html
  24. 1
      templates/invoices/print_batch.html
  25. 53
      templates/invoices/view.html
  26. 256
      templates/invoices/view.html.square_button_20260313-055733.bak
  27. 1
      templates/payments/edit.html
  28. 1
      templates/payments/list.html
  29. 1
      templates/payments/new.html
  30. 4
      templates/portal_dashboard.html
  31. 82
      templates/portal_forgot_password.html
  32. 36
      templates/portal_invoice_detail.html
  33. 209
      templates/portal_invoice_detail.html.bak_20260314-020444
  34. 187
      templates/portal_invoice_detail.html.square_button_20260313-055733.bak
  35. 9
      templates/portal_login.html
  36. 3
      templates/portal_set_password.html
  37. 1
      templates/reports/aging.html
  38. 1
      templates/reports/revenue.html
  39. 1
      templates/reports/revenue_print.html
  40. 1
      templates/services/edit.html
  41. 1
      templates/services/list.html
  42. 1
      templates/services/new.html
  43. 1
      templates/settings.html
  44. 1
      templates/subscriptions/list.html
  45. 1
      templates/subscriptions/new.html

36
PROJECT_STATE.md

@ -1,16 +1,26 @@
Project: OTB Billing
Version: v0.4.2
Last Updated: 2026-03-12
Status: Stable post-dedupe checkpoint
Version: v0.4.3
Last Updated: 2026-03-13
Status: Portal lifecycle complete
Current State:
- backend/app.py deduped and running cleanly under systemd.
- Portal supports email + one-time access code, forced password setup, dashboard, invoice detail, and secure PDF access.
- New/editable invoices write invoice_items automatically.
- Public portal host: portal.outsidethebox.top
- Billing host: otb-billing.outsidethebox.top
Current capabilities:
- Admin can enable/disable portal access
- Admin can generate/reset one-time access codes
- Admin can send portal invite email
- Admin can send portal password reset email
Client portal features:
- First login via single-use access code
- Forced password creation
- Email + password authentication after setup
- Invoice dashboard
- Invoice detail page
- Secure invoice PDF downloads
Infrastructure:
- Flask backend running via systemd
- MariaDB backend
- SMTP email integration
- Portal domain: portal.outsidethebox.top
- Billing admin: otb-billing.outsidethebox.top
Operations:
- sudo systemctl status otb_billing
- sudo systemctl restart otb_billing
- sudo journalctl -u otb_billing -f

33
README.md

@ -1,3 +1,36 @@
## v0.5.0 - 2026-03-14 22:01:59
- Added per-invoice Square payment links
- Added Square webhook validation and automatic invoice payment application
- Added duplicate webhook protection
- Added Square reconciliation page with filters and summary cards
- Added Accountbook page with today / month / YTD totals
- Added Accountbook CSV export
- Added reminder worker logging plus systemd service/timer
- Confirmed end-to-end automatic Square payment flow updates invoice, payments table, portal state, and email notifications
## v0.4.3 - 2026-03-13
Portal lifecycle features completed.
New functionality:
- Portal invite email from admin panel
- Portal password reset email from admin panel
- Single-use access code behavior clarified and enforced
- Portal password reset invalidates previous credentials
- Admin controls for portal enable/disable and code reset
- Portal access wording updated to reflect single-use token design
Existing functionality confirmed:
- Client portal login
- Forced password creation
- Invoice dashboard
- Invoice detail view
- PDF invoice download
- Deduplicated backend/app.py
This version represents the first **complete client portal credential lifecycle**.
## v0.4.2 - 2026-03-12
- Deduped backend/app.py and removed duplicated major route/function sections.
- Removed the text_for_pdf_routes snapshot hack from active runtime path.

2
VERSION

@ -1 +1 @@
v0.4.2
v0.5.0

1347
backend/app.py

File diff suppressed because it is too large Load Diff

3312
backend/app.py.deduped_candidate

File diff suppressed because it is too large Load Diff

6503
backend/app_cleanup_test.py

File diff suppressed because it is too large Load Diff

3312
backend/app_deduped_test.py

File diff suppressed because it is too large Load Diff

101
docs/db_reset_rebuild_reference.md

@ -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

123
scripts/invoice_reminder_worker.py

@ -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()

110
scripts/invoice_reminder_worker.py.bak_20260313-035553

@ -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()

117
scripts/invoice_reminder_worker.py.bak_20260313-035724

@ -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()

116
scripts/invoice_reminder_worker.py.bak_20260313-041145

@ -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()

1
templates/base.html

@ -4,6 +4,7 @@
<meta charset="utf-8">
<title>{{ page_title }}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

74
templates/clients/edit.html

@ -2,6 +2,7 @@
<html>
<head>
<title>Edit Client</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
@ -76,6 +77,79 @@ Notes<br>
</form>
<div style="margin-top:1.5rem;padding:1rem;border:1px solid rgba(255,255,255,0.16);border-radius:12px;background:rgba(255,255,255,0.03);">
<h3 style="margin-top:0;">Portal Access</h3>
<div style="margin-bottom:1rem;line-height:1.6;">
<div><strong>Portal Enabled:</strong> {{ "Yes" if client.portal_enabled else "No" }}</div>
<div><strong>Current Access Code:</strong> {{ client.portal_access_code or "Not set" }}</div>
<div><strong>Password Set At:</strong> {{ client.portal_password_set_at or "Not set" }}</div>
<div><strong>Access Code Created At:</strong> {{ client.portal_access_code_created_at or "Not set" }}</div>
<div><strong>Last Portal Login:</strong> {{ client.portal_last_login_at or "Never" }}</div>
</div>
{% if request.args.get("portal_reset_status") == "sent" %}
<div style="margin-top:0.75rem;margin-bottom:0.75rem;padding:0.85rem 1rem;border-radius:10px;border:1px solid rgba(80,200,120,0.35);background:rgba(80,200,120,0.10);color:#d8ffe2;">
Portal password reset email sent successfully.
</div>
{% elif request.args.get("portal_reset_status") == "missing_email" %}
<div style="margin-top:0.75rem;margin-bottom:0.75rem;padding:0.85rem 1rem;border-radius:10px;border:1px solid rgba(255,180,80,0.35);background:rgba(255,180,80,0.10);color:#ffe7c2;">
Portal password reset email was not sent because this client does not have an email address on file.
</div>
{% elif request.args.get("portal_reset_status") == "error" %}
<div style="margin-top:0.75rem;margin-bottom:0.75rem;padding:0.85rem 1rem;border-radius:10px;border:1px solid rgba(255,100,100,0.35);background:rgba(255,100,100,0.10);color:#ffd1d1;">
Portal password reset email could not be sent. Check SMTP settings and server logs.
</div>
{% endif %}
{% if request.args.get("portal_email_status") == "sent" %}
<div style="margin-top:0.75rem;margin-bottom:0.75rem;padding:0.85rem 1rem;border-radius:10px;border:1px solid rgba(80,200,120,0.35);background:rgba(80,200,120,0.10);color:#d8ffe2;">
Portal invite email sent successfully.
</div>
{% elif request.args.get("portal_email_status") == "missing_email" %}
<div style="margin-top:0.75rem;margin-bottom:0.75rem;padding:0.85rem 1rem;border-radius:10px;border:1px solid rgba(255,180,80,0.35);background:rgba(255,180,80,0.10);color:#ffe7c2;">
Portal invite email was not sent because this client does not have an email address on file.
</div>
{% elif request.args.get("portal_email_status") == "error" %}
<div style="margin-top:0.75rem;margin-bottom:0.75rem;padding:0.85rem 1rem;border-radius:10px;border:1px solid rgba(255,100,100,0.35);background:rgba(255,100,100,0.10);color:#ffd1d1;">
Portal invite email could not be sent. Check SMTP settings and server logs.
</div>
{% endif %}
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem;">
{% if client.portal_enabled %}
<form method="post" action="/clients/portal/disable/{{ client.id }}" style="margin:0;">
<button type="submit">Disable Portal</button>
</form>
{% else %}
<form method="post" action="/clients/portal/enable/{{ client.id }}" style="margin:0;">
<button type="submit">Enable Portal</button>
</form>
{% endif %}
<form method="post" action="/clients/portal/reset-code/{{ client.id }}" style="margin:0;">
<button type="submit">Generate / Reset Access Code</button>
</form>
<form method="post" action="/clients/portal/send-invite/{{ client.id }}" style="margin:0;">
<button type="submit">Send Portal Invite Email</button>
</form>
<form method="post" action="/clients/portal/send-password-reset/{{ client.id }}" style="margin:0;">
<button type="submit">Send Password Reset Email</button>
</form>
</div>
<div style="font-size:0.95rem;opacity:0.9;">
Resetting the access code disables the current portal password and forces the client to set a new password on next login.
The portal access code is intended as a one-time access token and is cleared after successful password setup.
</div>
</div>
{% include "footer.html" %}
</body>
</html>

1
templates/clients/list.html

@ -3,6 +3,7 @@
<head>
<title>Clients</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/clients/new.html

@ -2,6 +2,7 @@
<html>
<head>
<title>New Client</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/credits/add.html

@ -2,6 +2,7 @@
<html>
<head>
<title>Add Credit</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/credits/list.html

@ -2,6 +2,7 @@
<html>
<head>
<title>Client Credit Ledger</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

3
templates/dashboard.html

@ -3,6 +3,7 @@
<head>
<title>OTB Billing Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
@ -32,6 +33,8 @@
<a href="/services">Services</a>
<a href="/invoices">Invoices</a>
<a href="/payments">Payments</a>
<a href="/accountbook">Accountbook</a>
<a href="/square/reconciliation">Square Reconciliation</a>
<a href="/subscriptions">Subscriptions</a>
<a href="/reports/revenue">Revenue Report</a>
<a href="/reports/aging">Aging Report</a>

3
templates/health.html

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Health - OTB Billing</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.page-wrap {
padding: 1.5rem;
@ -66,6 +66,7 @@
word-break: break-word;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="page-wrap">

1
templates/invoices/edit.html

@ -37,6 +37,7 @@
margin-bottom: 15px;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/invoices/list.html

@ -53,6 +53,7 @@ select {
flex-wrap: wrap;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/invoices/new.html

@ -2,6 +2,7 @@
<html>
<head>
<title>New Invoice</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/invoices/print_batch.html

@ -101,6 +101,7 @@ body {
}
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

53
templates/invoices/view.html

@ -95,6 +95,7 @@ body {
}
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
@ -231,5 +232,57 @@ body {
</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>
<div style="margin-top:1rem;">
{% if (invoice.status or "")|lower != "paid" %}
<a href="/invoices/pay-square/{{ invoice.id }}" target="_blank" rel="noopener noreferrer"
style="display:inline-block;padding:10px 16px;background:#16a34a;color:#fff;text-decoration:none;border-radius:8px;font-weight:700;">
Pay Now (Square)
</a>
{% endif %}
</div>
</body>
</html>

256
templates/invoices/view.html.square_button_20260313-055733.bak

@ -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>

1
templates/payments/edit.html

@ -21,6 +21,7 @@
margin-bottom: 15px;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/payments/list.html

@ -46,6 +46,7 @@
opacity: 0.9;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/payments/new.html

@ -21,6 +21,7 @@
margin-bottom: 15px;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

4
templates/portal_dashboard.html

@ -4,7 +4,7 @@
<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/style.css">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top {
@ -70,6 +70,7 @@
color: #cbd5e1;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
@ -79,6 +80,7 @@
<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>

82
templates/portal_forgot_password.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>

36
templates/portal_invoice_detail.html

@ -4,7 +4,7 @@
<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/style.css">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top {
@ -76,6 +76,7 @@
color: #cbd5e1;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
@ -92,6 +93,13 @@
</div>
</div>
{% if (invoice.status or "")|lower == "paid" %}
<div style="background:#166534;color:#ecfdf5;padding:12px 14px;margin:0 0 20px 0;border-radius:8px;font-weight:600;border:1px solid rgba(255,255,255,0.12);">
✓ This invoice has been paid. Thank you!
</div>
{% endif %}
<div class="detail-grid">
<div class="detail-card">
<h3>Invoice</h3>
@ -154,6 +162,32 @@
</tbody>
</table>
{% if (invoice.status or "")|lower != "paid" %}
<div class="detail-card" style="margin-top:1.25rem;">
<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>
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer"
style="display:inline-block;padding:12px 18px;background:#16a34a;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:700;margin:8px 0;">
Pay Now
</a><br>
Please include your invoice number in the payment note.
</p>
<p>
If you have questions please contact
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">support@outsidethebox.top</a>
</p>
</div>
{% endif %}
{% if pdf_url %}
<div class="invoice-actions">
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a>

209
templates/portal_invoice_detail.html.bak_20260314-020444

@ -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>

187
templates/portal_invoice_detail.html.square_button_20260313-055733.bak

@ -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>

9
templates/portal_login.html

@ -4,7 +4,7 @@
<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/style.css">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; }
.portal-card {
@ -49,6 +49,7 @@
background: rgba(255,255,255,0.04);
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
@ -78,8 +79,14 @@
</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>

3
templates/portal_set_password.html

@ -4,7 +4,7 @@
<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/style.css">
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; }
.portal-card {
@ -43,6 +43,7 @@
background: rgba(255,255,255,0.04);
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">

1
templates/reports/aging.html

@ -47,6 +47,7 @@ th {
background: #f8f8f8;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/reports/revenue.html

@ -26,6 +26,7 @@ body { font-family: Arial, sans-serif; }
margin-right: 16px;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/reports/revenue_print.html

@ -21,6 +21,7 @@ th, td {
body { margin: 0; }
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/services/edit.html

@ -2,6 +2,7 @@
<html>
<head>
<title>Edit Service</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/services/list.html

@ -2,6 +2,7 @@
<html>
<head>
<title>Services</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/services/new.html

@ -2,6 +2,7 @@
<html>
<head>
<title>New Service</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/settings.html

@ -50,6 +50,7 @@ small {
color: #444;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/subscriptions/list.html

@ -7,6 +7,7 @@
.status-paused { color: #92400e; font-weight: bold; }
.status-cancelled { color: #991b1b; font-weight: bold; }
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

1
templates/subscriptions/new.html

@ -2,6 +2,7 @@
<html>
<head>
<title>New Subscription</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>

Loading…
Cancel
Save