Compare commits

..

21 Commits
v0.4.0 ... main

Author SHA1 Message Date
def 735b53588d Add paid via column to invoices list 6 days ago
def 4bd7fd5267 Show payment details on admin, portal, and PDF invoices 6 days ago
def 19531c68c6 Add ETHO and ETI wallet payment support 6 days ago
def 7692a56458 Remove stale portal side timer bindings 6 days ago
def 7a06680250 Make portal crypto tx submission retry-safe 6 days ago
def 717f4010a7 Use CAD payment value for crypto invoice reconciliation 6 days ago
def 2d4a104b0b Auto-reconcile pending crypto from plain portal invoice view 6 days ago
def fadfefba7e Reconcile crypto tx on invoice refresh with 3 minute window 6 days ago
def fc9e9ddfb0 Add crypto processing watcher and portal auto-refresh 6 days ago
def 309209c5c0 Add 3-RPC polling pools for Ethereum and Arbitrum 6 days ago
def 0efa554260 Retry RPC lookup after wallet tx submission 6 days ago
def 3ba7b07d1e Add explicit from address for wallet transaction requests 6 days ago
def aced21ab43 Improve portal crypto wallet UX with mobile deeplink and copy fallback 6 days ago
def 5e43b0f203 Add wallet-driven crypto payment submission for ETH and USDC 7 days ago
def c5edffa990 Add missing crypto payment address constant 1 week ago
def e631309322 Redesign portal invoice payments with Pay Now selector and crypto timers 1 week ago
def 0f51253b3a Freeze oracle quote snapshot on invoice creation 1 week ago
def 31037858ca v0.5.0 - Square auto-payment, reconciliation, accountbook, reminder timer 1 week ago
def 45fb6def10 Release v0.4.2: deduped app.py and secure portal PDF access 1 week ago
def 76560a2418 Clean repo noise and replace dashboard DB test link with health 1 week ago
def 931af482dc Release v0.4.1: portal auth, invoice detail, pre-refactor savepoint 1 week ago
  1. 7
      .gitignore
  2. 48
      PROJECT_STATE.md
  3. 50
      README.md
  4. 2
      VERSION
  5. 3420
      backend/app.py
  6. 3312
      backend/app.py.deduped_candidate
  7. 6503
      backend/app_cleanup_test.py
  8. 3312
      backend/app_deduped_test.py
  9. 101
      docs/db_reset_rebuild_reference.md
  10. 123
      scripts/invoice_reminder_worker.py
  11. 110
      scripts/invoice_reminder_worker.py.bak_20260313-035553
  12. 117
      scripts/invoice_reminder_worker.py.bak_20260313-035724
  13. 116
      scripts/invoice_reminder_worker.py.bak_20260313-041145
  14. 4
      sql/schema_v0.0.2.sql
  15. 1
      templates/base.html
  16. 74
      templates/clients/edit.html
  17. 1
      templates/clients/list.html
  18. 1
      templates/clients/new.html
  19. 1
      templates/credits/add.html
  20. 1
      templates/credits/list.html
  21. 5
      templates/dashboard.html
  22. 3
      templates/health.html
  23. 3
      templates/invoices/edit.html
  24. 3
      templates/invoices/list.html
  25. 1
      templates/invoices/new.html
  26. 1
      templates/invoices/print_batch.html
  27. 86
      templates/invoices/view.html
  28. 256
      templates/invoices/view.html.square_button_20260313-055733.bak
  29. 1
      templates/payments/edit.html
  30. 1
      templates/payments/list.html
  31. 1
      templates/payments/new.html
  32. 162
      templates/portal_dashboard.html
  33. 82
      templates/portal_forgot_password.html
  34. 727
      templates/portal_invoice_detail.html
  35. 209
      templates/portal_invoice_detail.html.bak_20260314-020444
  36. 187
      templates/portal_invoice_detail.html.square_button_20260313-055733.bak
  37. 96
      templates/portal_login.html
  38. 76
      templates/portal_set_password.html
  39. 1
      templates/reports/aging.html
  40. 1
      templates/reports/revenue.html
  41. 1
      templates/reports/revenue_print.html
  42. 1
      templates/services/edit.html
  43. 1
      templates/services/list.html
  44. 1
      templates/services/new.html
  45. 1
      templates/settings.html
  46. 1
      templates/subscriptions/list.html
  47. 1
      templates/subscriptions/new.html

7
.gitignore vendored

@ -6,3 +6,10 @@ venv/
instance/ instance/
*.sqlite *.sqlite
*.log *.log
# local backup/runtime clutter
backups/
run/
backup_pre_*/
*.bak.*
backend/app.py.fix_indent_backup

48
PROJECT_STATE.md

@ -1,32 +1,26 @@
Project: OTB Billing Project: OTB Billing
Version: v0.4.0 Version: v0.4.3
Last Updated: 2026-03-12 Last Updated: 2026-03-13
Status: Stable release checkpoint Status: Portal lifecycle complete
Current State: Current capabilities:
- Flask app runs under systemd as otb_billing.service. - Admin can enable/disable portal access
- Service starts through /home/def/otb_billing/run_dev.sh. - Admin can generate/reset one-time access codes
- Runtime environment is loaded from /home/def/otb_billing/.env by the shell wrapper. - Admin can send portal invite email
- App listens on 0.0.0.0:5050 for mintme webfront proxy access. - Admin can send portal password reset email
- /health renders as a styled page.
- /health.json provides raw machine-readable health data.
- Aging report is visually acceptable and readable.
- Reboot persistence confirmed.
Important Paths: Client portal features:
- Project root: /home/def/otb_billing - First login via single-use access code
- App entry: /home/def/otb_billing/backend/app.py - Forced password creation
- Health module: /home/def/otb_billing/backend/health.py - Email + password authentication after setup
- Runtime wrapper: /home/def/otb_billing/run_dev.sh - Invoice dashboard
- Env file: /home/def/otb_billing/.env - Invoice detail page
- Service unit: /etc/systemd/system/otb_billing.service - Secure invoice PDF downloads
- Repo copy of unit: /home/def/otb_billing/deploy/systemd/otb_billing.service
Operations: Infrastructure:
- sudo systemctl status otb_billing - Flask backend running via systemd
- sudo systemctl restart otb_billing - MariaDB backend
- sudo journalctl -u otb_billing -f - SMTP email integration
- Portal domain: portal.outsidethebox.top
- Billing admin: otb-billing.outsidethebox.top
Release Notes:
- This version is considered a finished product suitable for normal use.
- Further work, if any, should be touch-ups and enhancements rather than core stabilization.

50
README.md

@ -1,3 +1,53 @@
## 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.
- Added secure portal-safe invoice PDF route.
- Confirmed portal login, dashboard, invoice detail, invoice itemization, and PDF access flow.
- This version is the first clean post-dedupe checkpoint.
## v0.4.1 - 2026-03-12
- Added secure client portal login using email plus one-time access code.
- Added forced password setup on first portal login.
- Added client portal dashboard with invoice listing.
- Added portal invoice detail page.
- Wired new and editable invoices to create invoice_items automatically.
- Confirmed live admin edits refresh correctly in client portal views.
- Polished invoice edit notice bar contrast for dark theme.
- This version is the pre-refactor savepoint before cleaning duplicate sections in backend/app.py.
- System package dependency note: install `zip` on deployment hosts for release snapshot creation. - System package dependency note: install `zip` on deployment hosts for release snapshot creation.
## v0.4.0 - 2026-03-12 ## v0.4.0 - 2026-03-12
- Released stable service-managed build of OTB Billing. - Released stable service-managed build of OTB Billing.

2
VERSION

@ -1 +1 @@
v0.4.0 v0.5.0

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

4
sql/schema_v0.0.2.sql

@ -68,6 +68,10 @@ CREATE TABLE invoices (
total_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, total_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
amount_paid DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, amount_paid DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
status ENUM('draft','pending','paid','partial','overdue','cancelled') NOT NULL DEFAULT 'draft', status ENUM('draft','pending','paid','partial','overdue','cancelled') NOT NULL DEFAULT 'draft',
quote_fiat_amount DECIMAL(18,8) DEFAULT NULL,
quote_fiat_currency VARCHAR(16) DEFAULT NULL,
quote_expires_at DATETIME DEFAULT NULL,
oracle_snapshot LONGTEXT DEFAULT NULL,
issued_at DATETIME DEFAULT NULL, issued_at DATETIME DEFAULT NULL,
due_at DATETIME DEFAULT NULL, due_at DATETIME DEFAULT NULL,
paid_at DATETIME DEFAULT NULL, paid_at DATETIME DEFAULT NULL,

1
templates/base.html

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

74
templates/clients/edit.html

@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<title>Edit Client</title> <title>Edit Client</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head> </head>
<body> <body>
@ -76,6 +77,79 @@ Notes<br>
</form> </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" %} {% include "footer.html" %}
</body> </body>
</html> </html>

1
templates/clients/list.html

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

1
templates/clients/new.html

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

1
templates/credits/add.html

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

1
templates/credits/list.html

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

5
templates/dashboard.html

@ -3,6 +3,7 @@
<head> <head>
<title>OTB Billing Dashboard</title> <title>OTB Billing Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head> </head>
<body> <body>
@ -32,12 +33,14 @@
<a href="/services">Services</a> <a href="/services">Services</a>
<a href="/invoices">Invoices</a> <a href="/invoices">Invoices</a>
<a href="/payments">Payments</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="/subscriptions">Subscriptions</a>
<a href="/reports/revenue">Revenue Report</a> <a href="/reports/revenue">Revenue Report</a>
<a href="/reports/aging">Aging Report</a> <a href="/reports/aging">Aging Report</a>
<a href="/reports/accounting-package.zip">Monthly Accounting Package</a> <a href="/reports/accounting-package.zip">Monthly Accounting Package</a>
<a href="/settings">Settings / Config</a> <a href="/settings">Settings / Config</a>
<a href="/dbtest">DB Test</a> <a href="/health">Health</a>
</div> </div>
<form method="post" action="/reports/accounting-package/email" style="margin:0 0 16px 0;"> <form method="post" action="/reports/accounting-package/email" style="margin:0 0 16px 0;">

3
templates/health.html

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

3
templates/invoices/edit.html

@ -37,6 +37,7 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
</style> </style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head> </head>
<body> <body>
@ -64,8 +65,10 @@
</div> </div>
{% else %} {% else %}
<div class="info-box"> <div class="info-box">
<span style="color:#10203f !important;">
<strong>Manual status choices are limited to:</strong> draft, pending, or cancelled.<br> <strong>Manual status choices are limited to:</strong> draft, pending, or cancelled.<br>
Partial, paid, and overdue are system-managed from payment activity and due dates. Partial, paid, and overdue are system-managed from payment activity and due dates.
</span>
</div> </div>
{% endif %} {% endif %}

3
templates/invoices/list.html

@ -53,6 +53,7 @@ select {
flex-wrap: wrap; flex-wrap: wrap;
} }
</style> </style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head> </head>
<body> <body>
@ -129,6 +130,7 @@ select {
<th>Paid</th> <th>Paid</th>
<th>Remaining</th> <th>Remaining</th>
<th>Status</th> <th>Status</th>
<th>Paid Via</th>
<th>Issued</th> <th>Issued</th>
<th>Due</th> <th>Due</th>
<th>Actions</th> <th>Actions</th>
@ -146,6 +148,7 @@ select {
<td> <td>
<span class="status-badge status-{{ i.status }}">{{ i.status }}</span> <span class="status-badge status-{{ i.status }}">{{ i.status }}</span>
</td> </td>
<td>{{ i.paid_via or "-" }}</td>
<td>{{ i.issued_at|localtime }}</td> <td>{{ i.issued_at|localtime }}</td>
<td>{{ i.due_at|localtime }}</td> <td>{{ i.due_at|localtime }}</td>
<td> <td>

1
templates/invoices/new.html

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

1
templates/invoices/print_batch.html

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

86
templates/invoices/view.html

@ -95,6 +95,7 @@ body {
} }
} }
</style> </style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head> </head>
<body> <body>
@ -215,6 +216,39 @@ body {
</div> </div>
{% endif %} {% endif %}
{% if invoice_payments %}
<div class="notes-box">
<strong>Payments Applied</strong><br><br>
<table class="summary-table">
<tr>
<th>Method</th>
<th>Amount</th>
<th>Status</th>
<th>Received</th>
<th>Reference / TXID</th>
</tr>
{% for p in invoice_payments %}
<tr>
<td>{{ p.payment_method_label }}</td>
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td>
<td>{{ p.payment_status }}</td>
<td>{{ p.received_at_local }}</td>
<td>
{% if p.txid %}
{{ p.txid }}
{% elif p.reference %}
{{ p.reference }}
{% else %}
-
{% endif %}
{% if p.wallet_address %}<br><small>Wallet: {{ p.wallet_address }}</small>{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if settings.payment_terms %} {% if settings.payment_terms %}
<div class="notes-box"> <div class="notes-box">
<strong>Payment Terms</strong><br><br> <strong>Payment Terms</strong><br><br>
@ -231,5 +265,57 @@ body {
</div> </div>
{% include "footer.html" %} {% 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> </body>
</html> </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; margin-bottom: 15px;
} }
</style> </style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head> </head>
<body> <body>

1
templates/payments/list.html

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

1
templates/payments/new.html

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

162
templates/portal_dashboard.html

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Client Dashboard - OutsideTheBox</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top {
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap;
margin-bottom: 1rem;
}
.portal-actions a {
margin-left: 0.75rem;
text-decoration: underline;
}
.summary-grid {
display:grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap:1rem;
margin: 1rem 0 1.25rem 0;
}
.summary-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.03);
}
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; }
table.portal-table {
width: 100%;
border-collapse: collapse;
}
table.portal-table th, table.portal-table td {
padding: 0.8rem;
border-bottom: 1px solid rgba(255,255,255,0.12);
text-align: left;
}
table.portal-table th {
background: #e9eef7;
color: #10203f;
}
.invoice-link {
color: inherit;
text-decoration: underline;
font-weight: 600;
}
.status-badge {
display: inline-block;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 700;
}
.status-paid {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.status-pending {
background: rgba(245, 158, 11, 0.20);
color: #fbbf24;
}
.status-overdue {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.status-other {
background: rgba(148, 163, 184, 0.20);
color: #cbd5e1;
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
<div class="portal-top">
<div>
<h1>Client Dashboard</h1>
<p>{{ client.company_name or client.contact_name or client.email }}</p>
</div>
<div class="portal-actions">
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a>
<a href="https://outsidethebox.top/">Home</a>
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a>
<a href="/portal/logout">Logout</a>
</div>
</div>
<div class="summary-grid">
<div class="summary-card">
<h3>Total Invoices</h3>
<div>{{ invoice_count }}</div>
</div>
<div class="summary-card">
<h3>Total Outstanding</h3>
<div>{{ total_outstanding }}</div>
</div>
<div class="summary-card">
<h3>Total Paid</h3>
<div>{{ total_paid }}</div>
</div>
</div>
<h2>Invoices</h2>
<table class="portal-table">
<thead>
<tr>
<th>Invoice</th>
<th>Status</th>
<th>Created</th>
<th>Total</th>
<th>Paid</th>
<th>Outstanding</th>
</tr>
</thead>
<tbody>
{% for row in invoices %}
<tr>
<td>
<a class="invoice-link" href="/portal/invoice/{{ row.id }}">
{{ row.invoice_number or ("INV-" ~ row.id) }}
</a>
</td>
<td>
{% set s = (row.status or "")|lower %}
{% if s == "paid" %}
<span class="status-badge status-paid">{{ row.status }}</span>
{% elif s == "pending" %}
<span class="status-badge status-pending">{{ row.status }}</span>
{% elif s == "overdue" %}
<span class="status-badge status-overdue">{{ row.status }}</span>
{% else %}
<span class="status-badge status-other">{{ row.status }}</span>
{% endif %}
</td>
<td>{{ row.created_at }}</td>
<td>{{ row.total_amount }}</td>
<td>{{ row.amount_paid }}</td>
<td>{{ row.outstanding }}</td>
</tr>
{% else %}
<tr>
<td colspan="6">No invoices available.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function() {
setTimeout(function() {
window.location.reload();
}, 20000);
})();
</script>
{% include "footer.html" %}
</body>
</html>

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>

727
templates/portal_invoice_detail.html

@ -0,0 +1,727 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Detail - OutsideTheBox</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; }
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; }
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; }
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; }
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; }
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; }
table.portal-table th { background: #e9eef7; color: #10203f; }
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; }
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; }
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; }
.status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; }
.pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; }
.pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; }
.pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; }
.pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); }
.pay-panel.hidden { display: none; }
.pay-btn {
display:inline-block;
padding:12px 18px;
color:#ffffff;
text-decoration:none;
border-radius:8px;
font-weight:700;
border:none;
cursor:pointer;
margin:8px 0 0 0;
}
.pay-btn-square { background:#16a34a; }
.pay-btn-wallet { background:#2563eb; }
.pay-btn-mobile { background:#7c3aed; }
.pay-btn-copy { background:#374151; }
.error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; }
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; }
.snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); }
.snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; }
.snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; }
.snapshot-timer-box {
width: 220px;
min-height: 132px;
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
background: rgba(0,0,0,0.18);
display:flex;
flex-direction:column;
justify-content:center;
align-items:center;
text-align:center;
padding: 0.9rem;
}
.snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; }
.snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; }
.snapshot-timer-expired { color: #f87171; }
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; }
.quote-table th { background: #e9eef7; color: #10203f; }
.quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; }
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
.quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; }
.quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; }
.quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; }
.lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); }
.lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; }
.lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; }
.wallet-actions {
display:flex;
gap:0.75rem;
flex-wrap:wrap;
margin-top:0.9rem;
align-items:center;
}
.wallet-help {
margin-top: 0.85rem;
padding: 0.9rem 1rem;
border-radius: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.10);
}
.wallet-help h4 {
margin: 0 0 0.55rem 0;
font-size: 1rem;
}
.wallet-help p {
margin: 0.35rem 0;
}
.wallet-note { opacity:0.9; margin-top:0.65rem; }
.mono { font-family: monospace; }
.copy-row {
display:flex;
gap:0.5rem;
flex-wrap:wrap;
align-items:center;
margin-top:0.65rem;
}
.copy-target {
flex: 1 1 420px;
min-width: 220px;
}
.copy-status {
display:inline-block;
margin-left: 0.5rem;
opacity: 0.9;
}
@media (max-width: 820px) {
.snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; }
.snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; }
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
<div class="portal-top">
<div>
<h1>Invoice Detail</h1>
<p>{{ client.company_name or client.contact_name or client.email }}</p>
</div>
<div class="portal-actions">
<a href="/portal/dashboard">Back to Dashboard</a>
<a href="https://outsidethebox.top/">Home</a>
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a>
<a href="/portal/logout">Logout</a>
</div>
</div>
{% if (invoice.status or "")|lower == "paid" %}
<div class="success-box">✓ This invoice has been paid. Thank you!</div>
{% endif %}
{% if crypto_error %}
<div class="error-box">{{ crypto_error }}</div>
{% endif %}
<div class="detail-grid">
<div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div>
<div class="detail-card">
<h3>Status</h3>
{% set s = (invoice.status or "")|lower %}
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %}
<span class="status-badge status-pending">processing</span>
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span>
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span>
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span>
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %}
</div>
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div>
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div>
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div>
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div>
</div>
<h2>Invoice Items</h2>
<table class="portal-table">
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead>
<tbody>
{% for item in items %}
<tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr>
{% else %}
<tr><td colspan="4">No invoice line items found.</td></tr>
{% endfor %}
</tbody>
</table>
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %}
<div class="pay-card">
<h3>Pay Now</h3>
<div class="pay-selector-row">
<label for="payMethodSelect"><strong>Choose payment method:</strong></label>
<select id="payMethodSelect" class="pay-selector">
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option>
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option>
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option>
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option>
</select>
</div>
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}">
<p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p>
</div>
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}">
<p><strong>Credit Card (Square)</strong></p>
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a>
</div>
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}">
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %}
<div class="snapshot-wrap">
<div class="snapshot-header">
<div class="snapshot-meta">
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3>
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div>
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div>
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div>
{% if pending_crypto_payment %}
<div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div>
{% else %}
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div>
{% endif %}
</div>
{% if pending_crypto_payment and pending_crypto_payment.txid %}
<div class="snapshot-timer-box">
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div>
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div>
</div>
{% elif pending_crypto_payment %}
<div class="snapshot-timer-box">
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div>
<div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div>
</div>
{% else %}
<div class="snapshot-timer-box">
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div>
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div>
</div>
{% endif %}
</div>
{% if pending_crypto_payment and selected_crypto_option %}
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}">
<div class="lock-grid">
<div>
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3>
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div>
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div>
<code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code>
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div>
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code>
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
<div class="wallet-actions">
<button
type="button"
id="walletPayButton"
class="pay-btn pay-btn-wallet"
data-invoice-id="{{ invoice.id }}"
data-payment-id="{{ pending_crypto_payment.id }}"
data-asset="{{ selected_crypto_option.symbol }}"
data-chain-id="{{ selected_crypto_option.chain_id }}"
data-asset-type="{{ selected_crypto_option.asset_type }}"
data-to="{{ selected_crypto_option.wallet_address }}"
data-amount="{{ pending_crypto_payment.payment_amount }}"
data-decimals="{{ selected_crypto_option.decimals }}"
data-token-contract="{{ selected_crypto_option.token_contract or '' }}"
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}'
>
Open MetaMask / Rabby
</button>
<a
id="metamaskMobileLink"
href="#"
target="_blank"
rel="noopener noreferrer"
class="pay-btn pay-btn-mobile"
data-invoice-id="{{ invoice.id }}"
>
Open in MetaMask Mobile
</a>
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy">
Copy Payment Details
</button>
</div>
<div class="wallet-help">
<h4>Fastest way to pay</h4>
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p>
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p>
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p>
</div>
<div class="wallet-note">
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet.
</div>
<div class="copy-row">
<span id="walletStatusText"></span>
<span id="copyStatusText" class="copy-status"></span>
</div>
{% elif pending_crypto_payment.txid %}
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div>
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code>
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div>
{% elif pending_crypto_payment.lock_expired %}
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto">
<table class="quote-table">
<thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead>
<tbody>
{% for q in crypto_options %}
<tr>
<td>
{{ q.label }}
{% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %}
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %}
</td>
<td>{{ q.display_amount or "—" }}</td>
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td>
<td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td>
<td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endif %}
</div>
{% else %}
<p>No crypto quote snapshot is available for this invoice yet.</p>
{% endif %}
</div>
</div>
{% endif %}
{% if invoice_payments %}
<div class="detail-card" style="margin-top:1.25rem;">
<h3>Payments Applied</h3>
<table class="portal-table">
<thead>
<tr>
<th>Method</th>
<th>Amount</th>
<th>Status</th>
<th>Received</th>
<th>Reference / TXID</th>
</tr>
</thead>
<tbody>
{% for p in invoice_payments %}
<tr>
<td>{{ p.payment_method_label }}</td>
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td>
<td>{{ p.payment_status }}</td>
<td>{{ p.received_at_local }}</td>
<td>
{% if p.txid %}
{{ p.txid }}
{% elif p.reference %}
{{ p.reference }}
{% else %}
-
{% endif %}
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if pdf_url %}
<div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div>
{% endif %}
</div>
<script>
(function() {
const select = document.getElementById("payMethodSelect");
if (select) {
select.addEventListener("change", function() {
const value = this.value || "";
const url = new URL(window.location.href);
if (!value) {
url.searchParams.delete("pay");
url.searchParams.delete("asset");
url.searchParams.delete("payment_id");
url.searchParams.delete("crypto_error");
url.searchParams.delete("refresh_quote");
} else {
url.searchParams.set("pay", value);
if (value !== "crypto") {
url.searchParams.delete("asset");
url.searchParams.delete("payment_id");
url.searchParams.delete("crypto_error");
url.searchParams.delete("refresh_quote");
} else {
url.searchParams.delete("asset");
url.searchParams.delete("payment_id");
url.searchParams.delete("crypto_error");
url.searchParams.set("refresh_quote", "1");
}
}
window.location.href = url.toString();
});
}
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) {
const valueEl = document.getElementById(valueId);
const labelEl = document.getElementById(labelId);
if (!valueEl || !expireIso) return;
function tick() {
const end = new Date(expireIso).getTime();
const now = Date.now();
const diff = Math.max(0, Math.floor((end - now) / 1000));
if (diff <= 0) {
valueEl.textContent = "00:00";
valueEl.classList.add("snapshot-timer-expired");
if (labelEl) {
labelEl.textContent = expiredMessage;
labelEl.classList.add("snapshot-timer-expired");
}
if (disableSelector) {
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true);
}
const lockBox = document.getElementById("lockBox");
if (lockBox) lockBox.classList.add("expired");
return;
}
const m = String(Math.floor(diff / 60)).padStart(2, "0");
const s = String(diff % 60).padStart(2, "0");
valueEl.textContent = `${m}:${s}`;
setTimeout(tick, 250);
}
tick();
}
const quoteTimer = document.getElementById("quoteTimerValue");
if (quoteTimer && quoteTimer.dataset.expiry) {
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button");
}
const lockTimer = document.getElementById("lockTimerValue");
if (lockTimer && lockTimer.dataset.expiry) {
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton");
}
const processingTimer = document.getElementById("processingTimerValue");
if (processingTimer && processingTimer.dataset.expiry) {
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null);
}
function toHexBigIntFromDecimal(amountText, decimals) {
const text = String(amountText || "0");
const parts = text.split(".");
const whole = parts[0] || "0";
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals);
const combined = (whole + frac).replace(/^0+/, "") || "0";
return "0x" + BigInt(combined).toString(16);
}
function erc20TransferData(to, amountText, decimals) {
const method = "a9059cbb";
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0");
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0");
return "0x" + method + addr + amtHex;
}
async function switchChain(chainId, chainAddParams) {
const hexChainId = "0x" + Number(chainId).toString(16);
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: hexChainId }]
});
return;
} catch (err) {
const code = err && (err.code ?? err?.data?.originalError?.code);
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) {
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [chainAddParams]
});
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: hexChainId }]
});
return;
}
throw err;
}
}
function buildMetaMaskMobileLink() {
const currentUrl = window.location.href;
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, "");
}
const mmLink = document.getElementById("metamaskMobileLink");
if (mmLink) {
mmLink.href = buildMetaMaskMobileLink();
}
async function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
const copyBtn = document.getElementById("copyDetailsButton");
if (copyBtn) {
copyBtn.addEventListener("click", async function() {
const copyStatus = document.getElementById("copyStatusText");
const walletAddress = document.getElementById("walletAddressText")?.textContent || "";
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || "";
const amount = document.getElementById("walletPayButton")?.dataset.amount || "";
const asset = document.getElementById("walletPayButton")?.dataset.asset || "";
const payload =
`Asset: ${asset}
Amount: ${amount} ${asset}
Wallet: ${walletAddress}
Reference: ${invoiceRef}`;
try {
await copyText(payload);
if (copyStatus) copyStatus.textContent = "Payment details copied.";
} catch (err) {
if (copyStatus) copyStatus.textContent = "Copy failed.";
}
});
}
const walletButton = document.getElementById("walletPayButton");
function pendingTxStorageKey(invoiceId, paymentId) {
return `otb_pending_tx_${invoiceId}_${paymentId}`;
}
async function submitTxHash(invoiceId, paymentId, asset, txHash) {
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
payment_id: paymentId,
asset: asset,
tx_hash: txHash
})
});
let data = {};
try {
data = await res.json();
} catch (err) {
data = { ok: false, error: "invalid_json_response" };
}
if (!res.ok || !data.ok) {
throw new Error(data.error || `submit_failed_http_${res.status}`);
}
return data;
}
async function tryRecoverPendingTxFromStorage() {
const invoiceId = "{{ invoice.id }}";
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}";
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}";
if (!invoiceId || !paymentId || !asset) return;
{% if pending_crypto_payment and pending_crypto_payment.txid %}
return;
{% endif %}
const key = pendingTxStorageKey(invoiceId, paymentId);
const savedTx = localStorage.getItem(key);
if (!savedTx || !savedTx.startsWith("0x")) return;
const walletStatus = document.getElementById("walletStatusText");
try {
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission...";
await submitTxHash(invoiceId, paymentId, asset, savedTx);
localStorage.removeItem(key);
const url = new URL(window.location.href);
url.searchParams.set("pay", "crypto");
url.searchParams.set("asset", asset);
url.searchParams.set("payment_id", paymentId);
window.location.href = url.toString();
} catch (err) {
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`;
}
}
if (walletButton) {
walletButton.addEventListener("click", async function() {
const walletStatus = document.getElementById("walletStatusText");
const invoiceId = this.dataset.invoiceId;
const paymentId = this.dataset.paymentId;
const asset = this.dataset.asset;
const chainId = this.dataset.chainId;
const assetType = this.dataset.assetType;
const to = this.dataset.to;
const amount = this.dataset.amount;
const decimals = Number(this.dataset.decimals || "18");
const tokenContract = this.dataset.tokenContract || "";
let chainAddParams = null;
try {
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null;
} catch (err) {
chainAddParams = null;
}
const setStatus = (msg) => {
if (walletStatus) walletStatus.textContent = msg;
};
if (!window.ethereum || !window.ethereum.request) {
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile.");
return;
}
try {
this.disabled = true;
setStatus("Opening wallet...");
await window.ethereum.request({ method: "eth_requestAccounts" });
if (chainId && chainId !== "None" && chainId !== "") {
try {
await switchChain(Number(chainId), chainAddParams);
} catch (err) {
setStatus(`Chain switch failed: ${err.message || err}`);
this.disabled = false;
return;
}
}
let txParams;
if (assetType === "token" && tokenContract) {
txParams = {
from: (await window.ethereum.request({ method: "eth_accounts" }))[0],
to: tokenContract,
data: erc20TransferData(to, amount, decimals),
value: "0x0"
};
} else {
txParams = {
from: (await window.ethereum.request({ method: "eth_accounts" }))[0],
to: to,
value: toHexBigIntFromDecimal(amount, decimals)
};
}
setStatus("Waiting for wallet confirmation...");
const txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [txParams]
});
if (!txHash || !String(txHash).startsWith("0x")) {
throw new Error("wallet did not return a tx hash");
}
const storageKey = pendingTxStorageKey(invoiceId, paymentId);
localStorage.setItem(storageKey, String(txHash));
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`);
await submitTxHash(invoiceId, paymentId, asset, txHash);
localStorage.removeItem(storageKey);
setStatus("Transaction submitted. Reloading into processing view...");
const url = new URL(window.location.href);
url.searchParams.set("pay", "crypto");
url.searchParams.set("asset", asset);
url.searchParams.set("payment_id", paymentId);
window.location.href = url.toString();
} catch (err) {
setStatus(`Wallet submit failed: ${err.message || err}`);
this.disabled = false;
}
});
}
tryRecoverPendingTxFromStorage();
})();
</script>
<script>
(function() {
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }};
if (processingAutoRefreshEnabled) {
setTimeout(function() {
window.location.reload();
}, 10000);
}
})();
</script>
{% include "footer.html" %}
</body>
</html>

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>

96
templates/portal_login.html

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Client Portal - OutsideTheBox</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; }
.portal-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 16px;
padding: 1.4rem;
background: rgba(255,255,255,0.03);
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; }
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; }
.portal-form { display: grid; gap: 0.9rem; }
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; }
.portal-form input {
width: 100%;
padding: 0.8rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.05);
color: inherit;
box-sizing: border-box;
}
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; }
.portal-btn {
display: inline-block;
padding: 0.8rem 1rem;
border-radius: 10px;
text-decoration: none;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06);
color: inherit;
cursor: pointer;
}
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; }
.portal-links { margin-top: 1rem; }
.portal-links a { margin-right: 1rem; }
.portal-msg {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.16);
background: rgba(255,255,255,0.04);
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
<div class="portal-card">
<h1>OutsideTheBox Client Portal</h1>
<p class="portal-sub">Secure access for invoices, balances, and account information.</p>
{% if portal_message %}
<div class="portal-msg">{{ portal_message }}</div>
{% endif %}
<form class="portal-form" method="post" action="/portal/login">
<div>
<label for="email">Email Address</label>
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required>
</div>
<div>
<label for="credential">Access Code or Password</label>
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required>
</div>
<div class="portal-actions">
<button class="portal-btn" type="submit">Sign In</button>
<a class="portal-btn" href="https://outsidethebox.top/">Home</a>
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a>
</div>
</form>
<div style="margin-top:15px;">
<a href="/portal/forgot-password">Forgot your password?</a>
</div>
<p class="portal-note">
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password.
This access code is single-use and is cleared after password setup. Future logins use your email address and password.
</p>
</div>
</div>
{% include "footer.html" %}
</body>
</html>

76
templates/portal_set_password.html

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set Portal Password - OutsideTheBox</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; }
.portal-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 16px;
padding: 1.4rem;
background: rgba(255,255,255,0.03);
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
}
.portal-form { display: grid; gap: 0.9rem; }
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; }
.portal-form input {
width: 100%;
padding: 0.8rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.05);
color: inherit;
box-sizing: border-box;
}
.portal-btn {
display: inline-block;
padding: 0.8rem 1rem;
border-radius: 10px;
text-decoration: none;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06);
color: inherit;
cursor: pointer;
}
.portal-msg {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.16);
background: rgba(255,255,255,0.04);
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="portal-wrap">
<div class="portal-card">
<h1>Create Your Portal Password</h1>
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p>
{% if portal_message %}
<div class="portal-msg">{{ portal_message }}</div>
{% endif %}
<form class="portal-form" method="post" action="/portal/set-password">
<div>
<label for="password">New Password</label>
<input id="password" name="password" type="password" required>
</div>
<div>
<label for="password2">Confirm Password</label>
<input id="password2" name="password2" type="password" required>
</div>
<div>
<button class="portal-btn" type="submit">Set Password</button>
</div>
</form>
</div>
</div>
{% include "footer.html" %}
</body>
</html>

1
templates/reports/aging.html

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

1
templates/reports/revenue.html

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

1
templates/reports/revenue_print.html

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

1
templates/services/edit.html

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

1
templates/services/list.html

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

1
templates/services/new.html

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

1
templates/settings.html

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

1
templates/subscriptions/list.html

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

1
templates/subscriptions/new.html

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

Loading…
Cancel
Save