From acaa2b59da8ebbc5a6bf6749044b478fa0a40dff Mon Sep 17 00:00:00 2001 From: def Date: Wed, 11 Mar 2026 05:56:29 +0000 Subject: [PATCH] Add recurring billing, aging report, client balances, and UI polish --- PROJECT_STATE.md | 20 ++ VERSION | 2 +- backend/app.py | 486 +++++++++++++++++++++++++----- docs/test_feedback.md | 89 +++--- templates/clients/list.html | 74 +++-- templates/dashboard.html | 103 ++++--- templates/footer.html | 374 ++++++++++++++++++++++- templates/reports/aging.html | 71 +++++ templates/subscriptions/list.html | 62 ++++ templates/subscriptions/new.html | 112 +++++++ 10 files changed, 1193 insertions(+), 200 deletions(-) create mode 100644 templates/reports/aging.html create mode 100644 templates/subscriptions/list.html create mode 100644 templates/subscriptions/new.html diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index a5a85e1..375c259 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -328,3 +328,23 @@ python3 backend/app.py During active development, run in a visible terminal so logs stay visible. 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 diff --git a/VERSION b/VERSION index 937cd78..ef9b4e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3.1 +v0.4.0-dev diff --git a/backend/app.py b/backend/app.py index bbf8543..7acf5fa 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,11 +1,12 @@ from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify from db import get_db_connection 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 decimal import Decimal, InvalidOperation from pathlib import Path from email.message import EmailMessage +from dateutil.relativedelta import relativedelta from io import BytesIO, StringIO import csv @@ -193,6 +194,136 @@ def generate_invoice_number(): 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 = { "business_name": "OTB Billing", "business_tagline": "By a contractor, for contractors", @@ -689,6 +820,253 @@ def email_accounting_package(): except Exception: 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("/") def index(): refresh_overdue_invoices() @@ -706,6 +1084,7 @@ def index(): SELECT COUNT(*) AS outstanding_invoices FROM invoices WHERE status IN ('pending', 'partial', 'overdue') + AND (total_amount - amount_paid) > 0 """) outstanding_invoices = cursor.fetchone()["outstanding_invoices"] @@ -714,102 +1093,51 @@ def index(): FROM payments 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() + app_settings = get_app_settings() + return render_template( "dashboard.html", total_clients=total_clients, active_services=active_services, outstanding_invoices=outstanding_invoices, + outstanding_balance=outstanding_balance, revenue_received=revenue_received, + app_settings=app_settings, ) -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): +@app.route("/clients") +def clients(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) + cursor.execute(""" SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC + c.*, + COALESCE(( + SELECT SUM(i.total_amount - i.amount_paid) + FROM invoices i + WHERE i.client_id = c.id + AND i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ), 0) AS outstanding_balance + FROM clients c + ORDER BY c.company_name """) - 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() - 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) @app.route("/clients/new", methods=["GET", "POST"]) diff --git a/docs/test_feedback.md b/docs/test_feedback.md index af0c8e8..a6d42dd 100644 --- a/docs/test_feedback.md +++ b/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. - Mark status as `open`, `reviewed`, `planned`, `fixed`, or `wontfix`. ---- +------------------------------------------------------------ ## Feedback Entry Template -### Entry ID: FB-0001 -- Date: -- Tester: -- Area: - - clients - - services - - invoices - - payments - - subscriptions - - reports - - exports - - email - - settings - - installer - - other -- Type: - - bug - - usability - - bookkeeping - - accounting - - feature request - - reporting - - export - - wording - - other -- Severity: - - low - - medium - - high -- Summary: -- Steps to reproduce: -- Expected result: -- Actual result: -- Suggested change: -- Status: open -- Notes: - ---- +Entry ID: FB-0001 +Date: +Tester: +Area: + clients + services + invoices + payments + subscriptions + reports + exports + email + settings + installer + other + +Type: + bug + usability + bookkeeping + accounting + feature request + reporting + export + wording + other + +Severity: + low + medium + high + +Summary: + +Steps to reproduce: + +Expected result: + +Actual result: + +Suggested change: + +Status: open + +Notes: + +------------------------------------------------------------ ## Active Feedback - +Copy the template above for each new item. diff --git a/templates/clients/list.html b/templates/clients/list.html index 2946db9..8bec526 100644 --- a/templates/clients/list.html +++ b/templates/clients/list.html @@ -2,44 +2,54 @@ Clients + -

Clients

+
+

Clients

-

Home

-

Add Client

-

Export CSV

+

Home

+

Add Client

+

Export CSV

- - - - - - - - - - - +
IDCodeCompanyContactEmailPhoneStatusActions
+ + + + + + + + + + + -{% for c in clients %} - - - - - - - - - - -{% endfor %} - -
IDCodeCompanyContactEmailPhoneStatusBalanceActions
{{ c.id }}{{ c.client_code }}{{ c.company_name }}{{ c.contact_name }}{{ c.email }}{{ c.phone }}{{ c.status }} - Edit | - Ledger -
+ {% for c in clients %} + + {{ c.id }} + {{ c.client_code }} + {{ c.company_name }} + {{ c.contact_name }} + {{ c.email }} + {{ c.phone }} + {{ c.status }} + + {% if c.outstanding_balance and c.outstanding_balance > 0 %} + {{ c.outstanding_balance|money('CAD') }} + {% else %} + 0.00 + {% endif %} + + + Edit | + Ledger + + + {% endfor %} + +
{% include "footer.html" %} diff --git a/templates/dashboard.html b/templates/dashboard.html index 6e6a662..2a1e5c0 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -2,58 +2,67 @@ OTB Billing Dashboard + +
+ {% if app_settings.business_logo_url %} +
+ Logo +
+ {% endif %} -{% if app_settings.business_logo_url %} -
- -
-{% endif %} -

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

-{% if request.args.get('pkg_email') == '1' %} -
- Accounting package emailed successfully. -
-{% endif %} -{% if request.args.get('pkg_email_failed') == '1' %} -
- Accounting package email failed. Check SMTP settings or server log. -
-{% endif %} -{% if request.args.get('pkg_email_failed') == '1' %} -
- Accounting package email failed. Check SMTP settings or server log. +

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

+ + {% if request.args.get('pkg_email') == '1' %} +
+ Accounting package emailed successfully. +
+ {% endif %} + + {% if request.args.get('pkg_email_failed') == '1' %} +
+ Accounting package email failed. Check SMTP settings or server log. +
+ {% endif %} + + + +
+ +
+ + + + + + + + + + + + + + + + +
Total ClientsActive ServicesOutstanding InvoicesOutstanding Balance (CAD)Revenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ outstanding_balance|money('CAD') }}{{ revenue_received|money('CAD') }}
+ +

Displayed times are shown in Eastern Time (Toronto).

-{% endif %} - -

Clients

-

Services

-

Invoices

-

Payments

-

Revenue Report

-

Monthly Accounting Package

-
-

Settings / Config

-

DB Test

- - - - - - - - - - - - - - -
Total ClientsActive ServicesOutstanding InvoicesRevenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ revenue_received|money('CAD') }}
- -

Displayed times are shown in Eastern Time (Toronto).

{% include "footer.html" %} diff --git a/templates/footer.html b/templates/footer.html index 7fa0c0d..1d67be7 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -1,4 +1,376 @@ + + +
+ Theme + +
+
-
+ + + diff --git a/templates/reports/aging.html b/templates/reports/aging.html new file mode 100644 index 0000000..eccdbef --- /dev/null +++ b/templates/reports/aging.html @@ -0,0 +1,71 @@ + + + +Accounts Receivable Aging + + + + +

Accounts Receivable Aging

+ +

Home

+ + + + + + + + + + + + + {% for row in aging_rows %} + + + + + + + + + + {% endfor %} + + + + + + + + + + +
ClientCurrent1–3031–6061–9090+Total
{{ row.client }}{{ row.current|money('CAD') }}{{ row.d30|money('CAD') }}{{ row.d60|money('CAD') }}{{ row.d90|money('CAD') }}{{ row.d90p|money('CAD') }}{{ row.total|money('CAD') }}
TOTAL{{ totals.current|money('CAD') }}{{ totals.d30|money('CAD') }}{{ totals.d60|money('CAD') }}{{ totals.d90|money('CAD') }}{{ totals.d90p|money('CAD') }}{{ totals.total|money('CAD') }}
+ +{% include "footer.html" %} + + diff --git a/templates/subscriptions/list.html b/templates/subscriptions/list.html new file mode 100644 index 0000000..757208a --- /dev/null +++ b/templates/subscriptions/list.html @@ -0,0 +1,62 @@ + + + +Subscriptions + + + + +

Subscriptions

+ +

Home

+

Add Subscription

+ +
+ +
+ +{% if request.args.get('run_count') is not none %} +
+ Recurring billing run completed. Invoices created: {{ request.args.get('run_count') }} +
+{% endif %} + + + + + + + + + + + + + +{% for s in subscriptions %} + + + + + + + + + + +{% endfor %} +
IDClientSubscriptionServiceIntervalPriceNext InvoiceStatus
{{ s.id }}{{ s.client_code }} - {{ s.company_name }}{{ s.subscription_name }} + {% if s.service_code %} + {{ s.service_code }} - {{ s.service_name }} + {% else %} + - + {% endif %} + {{ s.billing_interval }}{{ s.price|money(s.currency_code) }} {{ s.currency_code }}{{ s.next_invoice_date }}{{ s.status }}
+ +{% include "footer.html" %} + + diff --git a/templates/subscriptions/new.html b/templates/subscriptions/new.html new file mode 100644 index 0000000..3ef4e6a --- /dev/null +++ b/templates/subscriptions/new.html @@ -0,0 +1,112 @@ + + + +New Subscription + + + +

Add Subscription

+ +

Home

+

Back to Subscriptions

+ +{% if errors %} +
+ Please fix the following: +
    + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+{% endif %} + +
+ +

+Client *
+ +

+ +

+Service (optional)
+ +

+ +

+Subscription Name *
+ +

+ +

+Billing Interval *
+ +

+ +

+Price *
+ +

+ +

+Currency *
+ +

+ +

+Start Date *
+ +

+ +

+Next Invoice Date *
+ +

+ +

+Status *
+ +

+ +

+Notes
+ +

+ +

+ +

+ +
+ +{% include "footer.html" %} + +