Browse Source

Release v0.4.1: portal auth, invoice detail, pre-refactor savepoint

main v0.4.1
def 1 week ago
parent
commit
931af482dc
  1. 20
      PROJECT_STATE.md
  2. 10
      README.md
  3. 2
      VERSION
  4. 3528
      backend/app.py
  5. 6
      templates/invoices/edit.html
  6. 151
      templates/portal_dashboard.html
  7. 166
      templates/portal_invoice_detail.html
  8. 89
      templates/portal_login.html
  9. 75
      templates/portal_set_password.html

20
PROJECT_STATE.md

@ -1,17 +1,19 @@
Project: OTB Billing
Version: v0.4.0
Version: v0.4.1
Last Updated: 2026-03-12
Status: Stable release checkpoint
Status: Stable savepoint before app.py dedupe/refactor
Current State:
- Flask app runs under systemd as otb_billing.service.
- Service starts through /home/def/otb_billing/run_dev.sh.
- Runtime environment is loaded from /home/def/otb_billing/.env by the shell wrapper.
- App listens on 0.0.0.0:5050 for mintme webfront proxy access.
- /health renders as a styled page.
- /health.json provides raw machine-readable health data.
- Aging report is visually acceptable and readable.
- Reboot persistence confirmed.
- Public portal host: portal.outsidethebox.top
- Portal supports email + one-time access code, then forced password setup.
- Portal dashboard shows invoices, totals, and invoice detail pages.
- New invoices and editable unlocked invoices now write invoice_items automatically.
- Existing older invoices may remain summary-only if no invoice_items were historically stored.
- backend/app.py still contains duplicated larger sections and is queued for controlled cleanup next.
Important Paths:
- Project root: /home/def/otb_billing
@ -27,6 +29,6 @@ Operations:
- sudo systemctl restart otb_billing
- sudo journalctl -u otb_billing -f
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.
Next Planned Work:
- Controlled dedupe/refactor of duplicate sections in backend/app.py
- Preserve all currently working portal and billing behavior during cleanup

10
README.md

@ -1,3 +1,13 @@
## 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.
## v0.4.0 - 2026-03-12
- Released stable service-managed build of OTB Billing.

2
VERSION

@ -1 +1 @@
v0.4.0
v0.4.1

3528
backend/app.py

File diff suppressed because it is too large Load Diff

6
templates/invoices/edit.html

@ -64,8 +64,10 @@
</div>
{% else %}
<div class="info-box">
<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.
<span style="color:#10203f !important;">
<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.
</span>
</div>
{% endif %}

151
templates/portal_dashboard.html

@ -0,0 +1,151 @@
<!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/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>
</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="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>
{% include "footer.html" %}
</body>
</html>

166
templates/portal_invoice_detail.html

@ -0,0 +1,166 @@
<!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/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>
</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="{{ pdf_url }}" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a>
</div>
{% endif %}
</div>
{% include "footer.html" %}
</body>
</html>

89
templates/portal_login.html

@ -0,0 +1,89 @@
<!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/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>
</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>
<p class="portal-note">
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password.
</p>
</div>
</div>
{% include "footer.html" %}
</body>
</html>

75
templates/portal_set_password.html

@ -0,0 +1,75 @@
<!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/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>
</head>
<body>
<div class="portal-wrap">
<div class="portal-card">
<h1>Create Your Portal Password</h1>
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p>
{% if portal_message %}
<div class="portal-msg">{{ portal_message }}</div>
{% endif %}
<form class="portal-form" method="post" action="/portal/set-password">
<div>
<label for="password">New Password</label>
<input id="password" name="password" type="password" required>
</div>
<div>
<label for="password2">Confirm Password</label>
<input id="password2" name="password2" type="password" required>
</div>
<div>
<button class="portal-btn" type="submit">Set Password</button>
</div>
</form>
</div>
</div>
{% include "footer.html" %}
</body>
</html>
Loading…
Cancel
Save