Browse Source

Add recurring billing, aging report, client balances, and UI polish

main
def 2 weeks ago
parent
commit
acaa2b59da
  1. 20
      PROJECT_STATE.md
  2. 2
      VERSION
  3. 486
      backend/app.py
  4. 89
      docs/test_feedback.md
  5. 14
      templates/clients/list.html
  6. 49
      templates/dashboard.html
  7. 374
      templates/footer.html
  8. 71
      templates/reports/aging.html
  9. 62
      templates/subscriptions/list.html
  10. 112
      templates/subscriptions/new.html

20
PROJECT_STATE.md

@ -328,3 +328,23 @@ python3 backend/app.py
During active development, run in a visible terminal so logs stay visible. During active development, run in a visible terminal so logs stay visible.
Do not rely on hidden/background launch during normal debug workflow. Do not rely on hidden/background launch during normal debug workflow.
=================================================
Version: v0.4.0-dev
Date: 2026-03-10
=================================================
Recurring billing foundation added.
New recurring billing capabilities:
- subscriptions table auto-created by app
- subscriptions list page
- new subscription creation page
- billing interval support: monthly / quarterly / yearly
- manual recurring billing run button
- automatic invoice creation for due subscriptions
- next_invoice_date auto-advances after invoice creation
Notes:
- first recurring billing slice is manual-run based
- cron/systemd timer automation can be added next

2
VERSION

@ -1 +1 @@
v0.3.1 v0.4.0-dev

486
backend/app.py

@ -1,11 +1,12 @@
from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify
from db import get_db_connection from db import get_db_connection
from utils import generate_client_code, generate_service_code from utils import generate_client_code, generate_service_code
from datetime import datetime, timezone from datetime import datetime, timezone, date, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from email.message import EmailMessage from email.message import EmailMessage
from dateutil.relativedelta import relativedelta
from io import BytesIO, StringIO from io import BytesIO, StringIO
import csv import csv
@ -193,6 +194,136 @@ def generate_invoice_number():
return f"INV-{number + 1:04d}" return f"INV-{number + 1:04d}"
def ensure_subscriptions_table():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
service_id INT UNSIGNED NULL,
subscription_name VARCHAR(255) NOT NULL,
billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly',
price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD',
start_date DATE NOT NULL,
next_invoice_date DATE NOT NULL,
status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active',
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_subscriptions_client_id (client_id),
KEY idx_subscriptions_service_id (service_id),
KEY idx_subscriptions_status (status),
KEY idx_subscriptions_next_invoice_date (next_invoice_date)
)
""")
conn.commit()
conn.close()
def get_next_subscription_date(current_date, billing_interval):
if isinstance(current_date, str):
current_date = datetime.strptime(current_date, "%Y-%m-%d").date()
if billing_interval == "yearly":
return current_date + relativedelta(years=1)
if billing_interval == "quarterly":
return current_date + relativedelta(months=3)
return current_date + relativedelta(months=1)
def generate_due_subscription_invoices(run_date=None):
ensure_subscriptions_table()
today = run_date or date.today()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
s.*,
c.client_code,
c.company_name,
srv.service_code,
srv.service_name
FROM subscriptions s
JOIN clients c ON s.client_id = c.id
LEFT JOIN services srv ON s.service_id = srv.id
WHERE s.status = 'active'
AND s.next_invoice_date <= %s
ORDER BY s.next_invoice_date ASC, s.id ASC
""", (today,))
due_subscriptions = cursor.fetchall()
created_count = 0
created_invoice_numbers = []
for sub in due_subscriptions:
invoice_number = generate_invoice_number()
due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time())
note_parts = [f"Recurring subscription: {sub['subscription_name']}"]
if sub.get("service_code"):
note_parts.append(f"Service: {sub['service_code']}")
if sub.get("service_name"):
note_parts.append(f"({sub['service_name']})")
if sub.get("notes"):
note_parts.append(f"Notes: {sub['notes']}")
note_text = " ".join(note_parts)
insert_cursor = conn.cursor()
insert_cursor.execute("""
INSERT INTO invoices
(
client_id,
service_id,
invoice_number,
currency_code,
total_amount,
subtotal_amount,
tax_amount,
issued_at,
due_at,
status,
notes
)
VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s)
""", (
sub["client_id"],
sub["service_id"],
invoice_number,
sub["currency_code"],
str(sub["price"]),
str(sub["price"]),
due_dt,
note_text,
))
next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"])
update_cursor = conn.cursor()
update_cursor.execute("""
UPDATE subscriptions
SET next_invoice_date = %s
WHERE id = %s
""", (next_date, sub["id"]))
created_count += 1
created_invoice_numbers.append(invoice_number)
conn.commit()
conn.close()
return {
"created_count": created_count,
"invoice_numbers": created_invoice_numbers,
"run_date": str(today),
}
APP_SETTINGS_DEFAULTS = { APP_SETTINGS_DEFAULTS = {
"business_name": "OTB Billing", "business_name": "OTB Billing",
"business_tagline": "By a contractor, for contractors", "business_tagline": "By a contractor, for contractors",
@ -689,6 +820,253 @@ def email_accounting_package():
except Exception: except Exception:
return redirect("/?pkg_email_failed=1") return redirect("/?pkg_email_failed=1")
@app.route("/subscriptions")
def subscriptions():
ensure_subscriptions_table()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
s.*,
c.client_code,
c.company_name,
srv.service_code,
srv.service_name
FROM subscriptions s
JOIN clients c ON s.client_id = c.id
LEFT JOIN services srv ON s.service_id = srv.id
ORDER BY s.id DESC
""")
subscriptions = cursor.fetchall()
conn.close()
return render_template("subscriptions/list.html", subscriptions=subscriptions)
@app.route("/subscriptions/new", methods=["GET", "POST"])
def new_subscription():
ensure_subscriptions_table()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
if request.method == "POST":
client_id = request.form.get("client_id", "").strip()
service_id = request.form.get("service_id", "").strip()
subscription_name = request.form.get("subscription_name", "").strip()
billing_interval = request.form.get("billing_interval", "").strip()
price = request.form.get("price", "").strip()
currency_code = request.form.get("currency_code", "").strip()
start_date_value = request.form.get("start_date", "").strip()
next_invoice_date = request.form.get("next_invoice_date", "").strip()
status = request.form.get("status", "").strip()
notes = request.form.get("notes", "").strip()
errors = []
if not client_id:
errors.append("Client is required.")
if not subscription_name:
errors.append("Subscription name is required.")
if billing_interval not in {"monthly", "quarterly", "yearly"}:
errors.append("Billing interval is required.")
if not price:
errors.append("Price is required.")
if not currency_code:
errors.append("Currency is required.")
if not start_date_value:
errors.append("Start date is required.")
if not next_invoice_date:
errors.append("Next invoice date is required.")
if status not in {"active", "paused", "cancelled"}:
errors.append("Status is required.")
if not errors:
try:
price_value = Decimal(str(price))
if price_value <= Decimal("0"):
errors.append("Price must be greater than zero.")
except Exception:
errors.append("Price must be a valid number.")
if errors:
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
conn.close()
return render_template(
"subscriptions/new.html",
clients=clients,
services=services,
errors=errors,
form_data={
"client_id": client_id,
"service_id": service_id,
"subscription_name": subscription_name,
"billing_interval": billing_interval,
"price": price,
"currency_code": currency_code,
"start_date": start_date_value,
"next_invoice_date": next_invoice_date,
"status": status,
"notes": notes,
},
)
insert_cursor = conn.cursor()
insert_cursor.execute("""
INSERT INTO subscriptions
(
client_id,
service_id,
subscription_name,
billing_interval,
price,
currency_code,
start_date,
next_invoice_date,
status,
notes
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
client_id,
service_id or None,
subscription_name,
billing_interval,
str(price_value),
currency_code,
start_date_value,
next_invoice_date,
status,
notes or None,
))
conn.commit()
conn.close()
return redirect("/subscriptions")
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
conn.close()
today_str = date.today().isoformat()
return render_template(
"subscriptions/new.html",
clients=clients,
services=services,
errors=[],
form_data={
"billing_interval": "monthly",
"currency_code": "CAD",
"start_date": today_str,
"next_invoice_date": today_str,
"status": "active",
},
)
@app.route("/subscriptions/run", methods=["POST"])
def run_subscriptions_now():
result = generate_due_subscription_invoices()
return redirect(f"/subscriptions?run_count={result['created_count']}")
@app.route("/reports/aging")
def report_aging():
refresh_overdue_invoices()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
c.id AS client_id,
c.client_code,
c.company_name,
i.invoice_number,
i.due_at,
i.total_amount,
i.amount_paid,
(i.total_amount - i.amount_paid) AS remaining
FROM invoices i
JOIN clients c ON i.client_id = c.id
WHERE i.status IN ('pending', 'partial', 'overdue')
AND (i.total_amount - i.amount_paid) > 0
ORDER BY c.company_name, i.due_at
""")
rows = cursor.fetchall()
conn.close()
today = datetime.utcnow().date()
grouped = {}
totals = {
"current": Decimal("0"),
"d30": Decimal("0"),
"d60": Decimal("0"),
"d90": Decimal("0"),
"d90p": Decimal("0"),
"total": Decimal("0"),
}
for row in rows:
client_id = row["client_id"]
client_label = f"{row['client_code']} - {row['company_name']}"
if client_id not in grouped:
grouped[client_id] = {
"client": client_label,
"current": Decimal("0"),
"d30": Decimal("0"),
"d60": Decimal("0"),
"d90": Decimal("0"),
"d90p": Decimal("0"),
"total": Decimal("0"),
}
remaining = to_decimal(row["remaining"])
if row["due_at"]:
due_date = row["due_at"].date()
age_days = (today - due_date).days
else:
age_days = 0
if age_days <= 0:
bucket = "current"
elif age_days <= 30:
bucket = "d30"
elif age_days <= 60:
bucket = "d60"
elif age_days <= 90:
bucket = "d90"
else:
bucket = "d90p"
grouped[client_id][bucket] += remaining
grouped[client_id]["total"] += remaining
totals[bucket] += remaining
totals["total"] += remaining
aging_rows = list(grouped.values())
return render_template(
"reports/aging.html",
aging_rows=aging_rows,
totals=totals
)
@app.route("/") @app.route("/")
def index(): def index():
refresh_overdue_invoices() refresh_overdue_invoices()
@ -706,6 +1084,7 @@ def index():
SELECT COUNT(*) AS outstanding_invoices SELECT COUNT(*) AS outstanding_invoices
FROM invoices FROM invoices
WHERE status IN ('pending', 'partial', 'overdue') WHERE status IN ('pending', 'partial', 'overdue')
AND (total_amount - amount_paid) > 0
""") """)
outstanding_invoices = cursor.fetchone()["outstanding_invoices"] outstanding_invoices = cursor.fetchone()["outstanding_invoices"]
@ -714,102 +1093,51 @@ def index():
FROM payments FROM payments
WHERE payment_status = 'confirmed' WHERE payment_status = 'confirmed'
""") """)
revenue_received = cursor.fetchone()["revenue_received"] revenue_received = to_decimal(cursor.fetchone()["revenue_received"])
cursor.execute("""
SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance
FROM invoices
WHERE status IN ('pending', 'partial', 'overdue')
AND (total_amount - amount_paid) > 0
""")
outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"])
conn.close() conn.close()
app_settings = get_app_settings()
return render_template( return render_template(
"dashboard.html", "dashboard.html",
total_clients=total_clients, total_clients=total_clients,
active_services=active_services, active_services=active_services,
outstanding_invoices=outstanding_invoices, outstanding_invoices=outstanding_invoices,
outstanding_balance=outstanding_balance,
revenue_received=revenue_received, revenue_received=revenue_received,
app_settings=app_settings,
) )
@app.route("/dbtest") @app.route("/clients")
def dbtest(): def clients():
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT NOW()")
result = cursor.fetchone()
conn.close()
return f"""
<h1>OTB Billing v{APP_VERSION}</h1>
<h2>Database OK</h2>
<p><a href="/">Home</a></p>
<p>DB server time (UTC): {result[0]}</p>
<p>Displayed local time: {fmt_local(result[0])}</p>
"""
except Exception as e:
return f"<h1>Database FAILED</h1><pre>{e}</pre>"
@app.route("/clients/export.csv")
def export_clients_csv():
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute(""" cursor.execute("""
SELECT SELECT
id, c.*,
client_code, COALESCE((
company_name, SELECT SUM(i.total_amount - i.amount_paid)
contact_name, FROM invoices i
email, WHERE i.client_id = c.id
phone, AND i.status IN ('pending', 'partial', 'overdue')
status, AND (i.total_amount - i.amount_paid) > 0
created_at, ), 0) AS outstanding_balance
updated_at FROM clients c
FROM clients ORDER BY c.company_name
ORDER BY id ASC
""") """)
rows = cursor.fetchall()
conn.close()
output = StringIO()
writer = csv.writer(output)
writer.writerow([
"id",
"client_code",
"company_name",
"contact_name",
"email",
"phone",
"status",
"created_at",
"updated_at",
])
for r in rows:
writer.writerow([
r.get("id", ""),
r.get("client_code", ""),
r.get("company_name", ""),
r.get("contact_name", ""),
r.get("email", ""),
r.get("phone", ""),
r.get("status", ""),
r.get("created_at", ""),
r.get("updated_at", ""),
])
response = make_response(output.getvalue())
response.headers["Content-Type"] = "text/csv; charset=utf-8"
response.headers["Content-Disposition"] = "attachment; filename=clients.csv"
return response
@app.route("/clients")
def clients():
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM clients ORDER BY id DESC")
clients = cursor.fetchall() clients = cursor.fetchall()
conn.close()
for client in clients:
client["credit_balance"] = get_client_credit_balance(client["id"])
conn.close()
return render_template("clients/list.html", clients=clients) return render_template("clients/list.html", clients=clients)
@app.route("/clients/new", methods=["GET", "POST"]) @app.route("/clients/new", methods=["GET", "POST"])

89
docs/test_feedback.md

@ -9,49 +9,58 @@ Use this file to collect tester feedback during sandbox / review cycles.
- Include screenshots or emailed notes separately if needed. - Include screenshots or emailed notes separately if needed.
- Mark status as `open`, `reviewed`, `planned`, `fixed`, or `wontfix`. - Mark status as `open`, `reviewed`, `planned`, `fixed`, or `wontfix`.
--- ------------------------------------------------------------
## Feedback Entry Template ## Feedback Entry Template
### Entry ID: FB-0001 Entry ID: FB-0001
- Date: Date:
- Tester: Tester:
- Area: Area:
- clients clients
- services services
- invoices invoices
- payments payments
- subscriptions subscriptions
- reports reports
- exports exports
- email email
- settings settings
- installer installer
- other other
- Type:
- bug Type:
- usability bug
- bookkeeping usability
- accounting bookkeeping
- feature request accounting
- reporting feature request
- export reporting
- wording export
- other wording
- Severity: other
- low
- medium Severity:
- high low
- Summary: medium
- Steps to reproduce: high
- Expected result:
- Actual result: Summary:
- Suggested change:
- Status: open Steps to reproduce:
- Notes:
Expected result:
---
Actual result:
Suggested change:
Status: open
Notes:
------------------------------------------------------------
## Active Feedback ## Active Feedback
<!-- Copy the template above for each new item --> Copy the template above for each new item.

14
templates/clients/list.html

@ -2,16 +2,18 @@
<html> <html>
<head> <head>
<title>Clients</title> <title>Clients</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>
<div class="otb-page">
<h1>Clients</h1> <h1>Clients</h1>
<p><a href="/">Home</a></p> <p><a href="/">Home</a></p>
<p><a href="/clients/new">Add Client</a></p> <p><a href="/clients/new">Add Client</a></p>
<p><a href="/clients/export.csv">Export CSV</a></p> <p><a href="/clients/export.csv">Export CSV</a></p>
<table border="1" cellpadding="6"> <table class="otb-table">
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Code</th> <th>Code</th>
@ -20,6 +22,7 @@
<th>Email</th> <th>Email</th>
<th>Phone</th> <th>Phone</th>
<th>Status</th> <th>Status</th>
<th>Balance</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@ -32,14 +35,21 @@
<td>{{ c.email }}</td> <td>{{ c.email }}</td>
<td>{{ c.phone }}</td> <td>{{ c.phone }}</td>
<td>{{ c.status }}</td> <td>{{ c.status }}</td>
<td class="otb-money">
{% if c.outstanding_balance and c.outstanding_balance > 0 %}
<strong>{{ c.outstanding_balance|money('CAD') }}</strong>
{% else %}
0.00
{% endif %}
</td>
<td> <td>
<a href="/clients/edit/{{ c.id }}">Edit</a> | <a href="/clients/edit/{{ c.id }}">Edit</a> |
<a href="/credits/{{ c.id }}">Ledger</a> <a href="/credits/{{ c.id }}">Ledger</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
{% include "footer.html" %} {% include "footer.html" %}
</body> </body>

49
templates/dashboard.html

@ -2,58 +2,67 @@
<html> <html>
<head> <head>
<title>OTB Billing Dashboard</title> <title>OTB Billing Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>
<div class="otb-page">
{% if app_settings.business_logo_url %} {% if app_settings.business_logo_url %}
<div style="margin-bottom:15px;"> <div style="margin-bottom:15px;">
<img src="{{ app_settings.business_logo_url }}" style="height:60px;"> <img src="{{ app_settings.business_logo_url }}" style="height:60px;" alt="Logo">
</div> </div>
{% endif %} {% endif %}
<h1>{{ app_settings.business_name or 'OTB Billing' }} Dashboard</h1>
<h1 class="otb-section-title">{{ app_settings.business_name or 'OTB Billing' }} Dashboard</h1>
{% if request.args.get('pkg_email') == '1' %} {% if request.args.get('pkg_email') == '1' %}
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;max-width:900px;"> <div class="otb-alert otb-alert-success">
Accounting package emailed successfully. Accounting package emailed successfully.
</div> </div>
{% endif %} {% endif %}
{% if request.args.get('pkg_email_failed') == '1' %} {% if request.args.get('pkg_email_failed') == '1' %}
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;max-width:900px;"> <div class="otb-alert otb-alert-error">
Accounting package email failed. Check SMTP settings or server log. Accounting package email failed. Check SMTP settings or server log.
</div> </div>
{% endif %} {% endif %}
{% if request.args.get('pkg_email_failed') == '1' %}
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;max-width:900px;"> <div class="otb-nav">
Accounting package email failed. Check SMTP settings or server log. <a href="/clients">Clients</a>
<a href="/services">Services</a>
<a href="/invoices">Invoices</a>
<a href="/payments">Payments</a>
<a href="/subscriptions">Subscriptions</a>
<a href="/reports/revenue">Revenue Report</a>
<a href="/reports/aging">Aging Report</a>
<a href="/reports/accounting-package.zip">Monthly Accounting Package</a>
<a href="/settings">Settings / Config</a>
<a href="/dbtest">DB Test</a>
</div> </div>
{% endif %}
<p><a href="/clients">Clients</a></p> <form method="post" action="/reports/accounting-package/email" style="margin:0 0 16px 0;">
<p><a href="/services">Services</a></p> <button type="submit">Email Accounting Package</button>
<p><a href="/invoices">Invoices</a></p> </form>
<p><a href="/payments">Payments</a></p>
<p><a href="/reports/revenue">Revenue Report</a></p> <table class="otb-table otb-summary-table">
<p><a href="/reports/accounting-package.zip">Monthly Accounting Package</a></p>
<form method="post" action="/reports/accounting-package/email" style="margin:0 0 16px 0;"><button type="submit">Email Accounting Package</button></form>
<p><a href="/settings">Settings / Config</a></p>
<p><a href="/dbtest">DB Test</a></p>
<table border="1" cellpadding="10">
<tr> <tr>
<th>Total Clients</th> <th>Total Clients</th>
<th>Active Services</th> <th>Active Services</th>
<th>Outstanding Invoices</th> <th>Outstanding Invoices</th>
<th>Outstanding Balance (CAD)</th>
<th>Revenue Received (CAD)</th> <th>Revenue Received (CAD)</th>
</tr> </tr>
<tr> <tr>
<td>{{ total_clients }}</td> <td>{{ total_clients }}</td>
<td>{{ active_services }}</td> <td>{{ active_services }}</td>
<td>{{ outstanding_invoices }}</td> <td>{{ outstanding_invoices }}</td>
<td>{{ outstanding_balance|money('CAD') }}</td>
<td>{{ revenue_received|money('CAD') }}</td> <td>{{ revenue_received|money('CAD') }}</td>
</tr> </tr>
</table> </table>
<p>Displayed times are shown in Eastern Time (Toronto).</p> <p>Displayed times are shown in Eastern Time (Toronto).</p>
</div>
{% include "footer.html" %} {% include "footer.html" %}
</body> </body>

374
templates/footer.html

@ -1,4 +1,376 @@
<style>
:root {
--otb-bg: #f5f7fb;
--otb-text: #1e293b;
--otb-card: #ffffff;
--otb-border: #cbd5e1;
--otb-link: #1d4ed8;
--otb-muted: #64748b;
--otb-shadow: 0 2px 8px rgba(15,23,42,0.08);
--otb-hover: #eef4ff;
--otb-success-bg: #dcfce7;
--otb-success-text: #166534;
--otb-warning-bg: #fef3c7;
--otb-warning-text: #92400e;
--otb-danger-bg: #fee2e2;
--otb-danger-text: #991b1b;
--otb-info-bg: #dbeafe;
--otb-info-text: #1d4ed8;
--otb-neutral-bg: #e5e7eb;
--otb-neutral-text: #374151;
}
body.otb-dark {
--otb-bg: #0f172a;
--otb-text: #e5e7eb;
--otb-card: #111827;
--otb-border: #334155;
--otb-link: #93c5fd;
--otb-muted: #94a3b8;
--otb-shadow: 0 2px 10px rgba(0,0,0,0.35);
--otb-hover: #172554;
--otb-success-bg: #052e16;
--otb-success-text: #86efac;
--otb-warning-bg: #451a03;
--otb-warning-text: #fcd34d;
--otb-danger-bg: #450a0a;
--otb-danger-text: #fca5a5;
--otb-info-bg: #172554;
--otb-info-text: #93c5fd;
--otb-neutral-bg: #1f2937;
--otb-neutral-text: #d1d5db;
}
body {
background: var(--otb-bg);
color: var(--otb-text);
font-family: Arial, Helvetica, sans-serif;
margin: 0;
padding: 24px;
transition: background 0.2s ease, color 0.2s ease;
}
a {
color: var(--otb-link);
}
img {
max-width: 100%;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.otb-page {
max-width: 1240px;
margin: 0 auto;
background: var(--otb-card);
border: 1px solid var(--otb-border);
box-shadow: var(--otb-shadow);
padding: 20px;
}
.otb-table,
table {
border-collapse: collapse;
width: 100%;
margin-top: 14px;
background: var(--otb-card);
}
.otb-table th,
.otb-table td,
table th,
table td {
border: 1px solid var(--otb-border);
padding: 8px 10px;
vertical-align: top;
}
.otb-table th,
table th {
text-align: left;
}
.otb-table tbody tr:hover,
table tbody tr:hover,
table tr:hover td {
background: var(--otb-hover);
}
.otb-money {
text-align: right;
white-space: nowrap;
}
.otb-alert {
padding: 10px;
margin-bottom: 15px;
max-width: 950px;
border: 1px solid;
}
.otb-alert-success {
background: var(--otb-success-bg);
border-color: var(--otb-success-text);
color: var(--otb-success-text);
}
.otb-alert-error {
background: var(--otb-danger-bg);
border-color: var(--otb-danger-text);
color: var(--otb-danger-text);
}
.otb-footer {
margin: 18px auto 0 auto;
max-width: 1240px;
font-size: 12px;
color: var(--otb-muted);
}
.otb-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 20px 0;
}
.otb-nav a,
.otb-nav button {
display: inline-block;
text-decoration: none;
padding: 9px 14px;
border: 1px solid var(--otb-border);
background: var(--otb-card);
color: var(--otb-text);
border-radius: 10px;
box-shadow: var(--otb-shadow);
}
.otb-nav a:hover,
.otb-nav button:hover {
background: var(--otb-hover);
}
.otb-section-title {
margin-top: 0;
}
.otb-status {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
letter-spacing: 0.02em;
white-space: nowrap;
}
.otb-status-paid,
.otb-status-active,
.otb-status-confirmed,
.otb-status-sent {
background: var(--otb-success-bg);
color: var(--otb-success-text);
}
.otb-status-pending,
.otb-status-partial,
.otb-status-warning,
.otb-status-paused {
background: var(--otb-warning-bg);
color: var(--otb-warning-text);
}
.otb-status-overdue,
.otb-status-cancelled,
.otb-status-reversed,
.otb-status-failed {
background: var(--otb-danger-bg);
color: var(--otb-danger-text);
}
.otb-status-draft,
.otb-status-current,
.otb-status-info {
background: var(--otb-info-bg);
color: var(--otb-info-text);
}
.otb-status-neutral {
background: var(--otb-neutral-bg);
color: var(--otb-neutral-text);
}
/* Toggle switch */
.otb-theme-toggle-wrap {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
background: var(--otb-card);
color: var(--otb-text);
border: 1px solid var(--otb-border);
box-shadow: var(--otb-shadow);
border-radius: 999px;
padding: 8px 12px;
}
.otb-theme-toggle-wrap .label {
font-size: 12px;
color: var(--otb-muted);
}
.otb-switch {
position: relative;
display: inline-block;
width: 46px;
height: 24px;
}
.otb-switch input {
opacity: 0;
width: 0;
height: 0;
}
.otb-slider {
position: absolute;
inset: 0;
background: #cbd5e1;
border-radius: 999px;
transition: 0.2s ease;
}
.otb-slider:before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.2s ease;
}
.otb-switch input:checked + .otb-slider {
background: #2563eb;
}
.otb-switch input:checked + .otb-slider:before {
transform: translateX(22px);
}
</style>
<div class="otb-theme-toggle-wrap">
<span class="label">Theme</span>
<label class="otb-switch">
<input type="checkbox" id="otbThemeToggle">
<span class="otb-slider"></span>
</label>
</div>
<hr> <hr>
<div style="font-size:12px;color:#666;"> <div class="otb-footer">
OTB Billing v{{ app_version }} OTB Billing v{{ app_version }}
</div> </div>
<script>
(function () {
const themeKey = "otb_theme";
const toggle = document.getElementById("otbThemeToggle");
function applyTheme(theme) {
if (theme === "dark") {
document.body.classList.add("otb-dark");
if (toggle) toggle.checked = true;
} else {
document.body.classList.remove("otb-dark");
if (toggle) toggle.checked = false;
}
}
const savedTheme = localStorage.getItem(themeKey) || "light";
applyTheme(savedTheme);
if (toggle) {
toggle.addEventListener("change", function () {
const nextTheme = toggle.checked ? "dark" : "light";
localStorage.setItem(themeKey, nextTheme);
applyTheme(nextTheme);
});
}
function normalizeStatusText(text) {
return (text || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-");
}
function statusClassFor(value) {
const normalized = normalizeStatusText(value);
if (["paid", "active", "confirmed", "sent"].includes(normalized)) {
return "otb-status otb-status-paid";
}
if (["pending", "partial", "paused"].includes(normalized)) {
return "otb-status otb-status-pending";
}
if (["overdue", "cancelled", "reversed", "failed"].includes(normalized)) {
return "otb-status otb-status-overdue";
}
if (["draft", "current"].includes(normalized)) {
return "otb-status otb-status-draft";
}
return "";
}
function enhanceStatusCells() {
const cells = document.querySelectorAll("td, span, div");
cells.forEach(function (el) {
if (el.dataset.otbStatusDone === "1") return;
if (el.children.length > 0) return;
const text = (el.textContent || "").trim();
const klass = statusClassFor(text);
if (!klass) return;
if (el.tagName.toLowerCase() === "span") {
el.className = (el.className ? el.className + " " : "") + klass;
} else if (el.tagName.toLowerCase() === "td" || el.tagName.toLowerCase() === "div") {
el.innerHTML = '<span class="' + klass + '">' + text + '</span>';
}
el.dataset.otbStatusDone = "1";
});
}
enhanceStatusCells();
})();
</script>

71
templates/reports/aging.html

@ -0,0 +1,71 @@
<!doctype html>
<html>
<head>
<title>Accounts Receivable Aging</title>
<style>
table {
border-collapse: collapse;
width: 100%;
max-width: 1200px;
}
th, td {
border: 1px solid #999;
padding: 8px;
text-align: left;
}
th {
background: #f2f2f2;
}
.money {
text-align: right;
white-space: nowrap;
}
.total-row {
font-weight: bold;
background: #f8f8f8;
}
</style>
</head>
<body>
<h1>Accounts Receivable Aging</h1>
<p><a href="/">Home</a></p>
<table>
<tr>
<th>Client</th>
<th>Current</th>
<th>1–30</th>
<th>31–60</th>
<th>61–90</th>
<th>90+</th>
<th>Total</th>
</tr>
{% for row in aging_rows %}
<tr>
<td>{{ row.client }}</td>
<td class="money">{{ row.current|money('CAD') }}</td>
<td class="money">{{ row.d30|money('CAD') }}</td>
<td class="money">{{ row.d60|money('CAD') }}</td>
<td class="money">{{ row.d90|money('CAD') }}</td>
<td class="money">{{ row.d90p|money('CAD') }}</td>
<td class="money"><strong>{{ row.total|money('CAD') }}</strong></td>
</tr>
{% endfor %}
<tr class="total-row">
<td>TOTAL</td>
<td class="money">{{ totals.current|money('CAD') }}</td>
<td class="money">{{ totals.d30|money('CAD') }}</td>
<td class="money">{{ totals.d60|money('CAD') }}</td>
<td class="money">{{ totals.d90|money('CAD') }}</td>
<td class="money">{{ totals.d90p|money('CAD') }}</td>
<td class="money">{{ totals.total|money('CAD') }}</td>
</tr>
</table>
{% include "footer.html" %}
</body>
</html>

62
templates/subscriptions/list.html

@ -0,0 +1,62 @@
<!doctype html>
<html>
<head>
<title>Subscriptions</title>
<style>
.status-active { color: #166534; font-weight: bold; }
.status-paused { color: #92400e; font-weight: bold; }
.status-cancelled { color: #991b1b; font-weight: bold; }
</style>
</head>
<body>
<h1>Subscriptions</h1>
<p><a href="/">Home</a></p>
<p><a href="/subscriptions/new">Add Subscription</a></p>
<form method="post" action="/subscriptions/run" style="margin: 0 0 16px 0;">
<button type="submit">Generate Due Invoices Now</button>
</form>
{% if request.args.get('run_count') is not none %}
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;max-width:900px;">
Recurring billing run completed. Invoices created: {{ request.args.get('run_count') }}
</div>
{% endif %}
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
<th>Client</th>
<th>Subscription</th>
<th>Service</th>
<th>Interval</th>
<th>Price</th>
<th>Next Invoice</th>
<th>Status</th>
</tr>
{% for s in subscriptions %}
<tr>
<td>{{ s.id }}</td>
<td>{{ s.client_code }} - {{ s.company_name }}</td>
<td>{{ s.subscription_name }}</td>
<td>
{% if s.service_code %}
{{ s.service_code }} - {{ s.service_name }}
{% else %}
-
{% endif %}
</td>
<td>{{ s.billing_interval }}</td>
<td>{{ s.price|money(s.currency_code) }} {{ s.currency_code }}</td>
<td>{{ s.next_invoice_date }}</td>
<td class="status-{{ s.status }}">{{ s.status }}</td>
</tr>
{% endfor %}
</table>
{% include "footer.html" %}
</body>
</html>

112
templates/subscriptions/new.html

@ -0,0 +1,112 @@
<!doctype html>
<html>
<head>
<title>New Subscription</title>
</head>
<body>
<h1>Add Subscription</h1>
<p><a href="/">Home</a></p>
<p><a href="/subscriptions">Back to Subscriptions</a></p>
{% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;">
<strong>Please fix the following:</strong>
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post">
<p>
Client *<br>
<select name="client_id" required>
<option value="">Select client</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if form_data.get('client_id') == (c.id|string) %}selected{% endif %}>
{{ c.client_code }} - {{ c.company_name }}
</option>
{% endfor %}
</select>
</p>
<p>
Service (optional)<br>
<select name="service_id">
<option value="">None</option>
{% for s in services %}
<option value="{{ s.id }}" {% if form_data.get('service_id') == (s.id|string) %}selected{% endif %}>
{{ s.service_code }} - {{ s.service_name }}
</option>
{% endfor %}
</select>
</p>
<p>
Subscription Name *<br>
<input name="subscription_name" value="{{ form_data.get('subscription_name', '') }}" required>
</p>
<p>
Billing Interval *<br>
<select name="billing_interval" required>
<option value="monthly" {% if form_data.get('billing_interval') == 'monthly' %}selected{% endif %}>monthly</option>
<option value="quarterly" {% if form_data.get('billing_interval') == 'quarterly' %}selected{% endif %}>quarterly</option>
<option value="yearly" {% if form_data.get('billing_interval') == 'yearly' %}selected{% endif %}>yearly</option>
</select>
</p>
<p>
Price *<br>
<input type="number" step="0.00000001" min="0.00000001" name="price" value="{{ form_data.get('price', '') }}" required>
</p>
<p>
Currency *<br>
<select name="currency_code" required>
<option value="CAD" {% if form_data.get('currency_code') == 'CAD' %}selected{% endif %}>CAD</option>
<option value="USD" {% if form_data.get('currency_code') == 'USD' %}selected{% endif %}>USD</option>
<option value="ETHO" {% if form_data.get('currency_code') == 'ETHO' %}selected{% endif %}>ETHO</option>
<option value="EGAZ" {% if form_data.get('currency_code') == 'EGAZ' %}selected{% endif %}>EGAZ</option>
<option value="ALT" {% if form_data.get('currency_code') == 'ALT' %}selected{% endif %}>ALT</option>
</select>
</p>
<p>
Start Date *<br>
<input type="date" name="start_date" value="{{ form_data.get('start_date', '') }}" required>
</p>
<p>
Next Invoice Date *<br>
<input type="date" name="next_invoice_date" value="{{ form_data.get('next_invoice_date', '') }}" required>
</p>
<p>
Status *<br>
<select name="status" required>
<option value="active" {% if form_data.get('status') == 'active' %}selected{% endif %}>active</option>
<option value="paused" {% if form_data.get('status') == 'paused' %}selected{% endif %}>paused</option>
<option value="cancelled" {% if form_data.get('status') == 'cancelled' %}selected{% endif %}>cancelled</option>
</select>
</p>
<p>
Notes<br>
<textarea name="notes" rows="5" cols="60">{{ form_data.get('notes', '') }}</textarea>
</p>
<p>
<button type="submit">Create Subscription</button>
</p>
</form>
{% include "footer.html" %}
</body>
</html>
Loading…
Cancel
Save