from flask import Flask, render_template, request, redirect from db import get_db_connection from utils import generate_client_code, generate_service_code from datetime import datetime, timezone from zoneinfo import ZoneInfo from decimal import Decimal, InvalidOperation app = Flask( __name__, template_folder="../templates", static_folder="../static", ) LOCAL_TZ = ZoneInfo("America/Toronto") def load_version(): try: with open("/home/def/otb_billing/VERSION", "r") as f: return f.read().strip() except Exception: return "unknown" APP_VERSION = load_version() @app.context_processor def inject_version(): return {"app_version": APP_VERSION} def fmt_local(dt_value): if not dt_value: return "" if isinstance(dt_value, str): try: dt_value = datetime.fromisoformat(dt_value) except ValueError: return str(dt_value) if dt_value.tzinfo is None: dt_value = dt_value.replace(tzinfo=timezone.utc) return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") def to_decimal(value): if value is None or value == "": return Decimal("0") try: return Decimal(str(value)) except (InvalidOperation, ValueError): return Decimal("0") def fmt_money(value, currency_code="CAD"): amount = to_decimal(value) if currency_code == "CAD": return f"{amount:.2f}" return f"{amount:.8f}" def refresh_overdue_invoices(): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" UPDATE invoices SET status = 'overdue' WHERE due_at IS NOT NULL AND due_at < UTC_TIMESTAMP() AND status IN ('pending', 'partial') """) conn.commit() conn.close() @app.template_filter("localtime") def localtime_filter(value): return fmt_local(value) @app.template_filter("money") def money_filter(value, currency_code="CAD"): return fmt_money(value, currency_code) @app.route("/") def index(): refresh_overdue_invoices() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") total_clients = cursor.fetchone()["total_clients"] cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") active_services = cursor.fetchone()["active_services"] cursor.execute(""" SELECT COUNT(*) AS outstanding_invoices FROM invoices WHERE status IN ('pending', 'partial', 'overdue') """) outstanding_invoices = cursor.fetchone()["outstanding_invoices"] cursor.execute(""" SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received FROM payments WHERE payment_status = 'confirmed' """) revenue_received = cursor.fetchone()["revenue_received"] conn.close() return render_template( "dashboard.html", total_clients=total_clients, active_services=active_services, outstanding_invoices=outstanding_invoices, revenue_received=revenue_received, ) @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") 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() return render_template("clients/list.html", clients=clients) @app.route("/clients/new", methods=["GET", "POST"]) def new_client(): if request.method == "POST": company_name = request.form["company_name"] contact_name = request.form["contact_name"] email = request.form["email"] phone = request.form["phone"] conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute("SELECT MAX(id) AS last_id FROM clients") result = cursor.fetchone() last_number = result["last_id"] if result["last_id"] else 0 client_code = generate_client_code(company_name, last_number) insert_cursor = conn.cursor() insert_cursor.execute( """ INSERT INTO clients (client_code, company_name, contact_name, email, phone) VALUES (%s, %s, %s, %s, %s) """, (client_code, company_name, contact_name, email, phone) ) conn.commit() conn.close() return redirect("/clients") return render_template("clients/new.html") @app.route("/clients/edit/", methods=["GET", "POST"]) def edit_client(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": company_name = request.form.get("company_name", "").strip() contact_name = request.form.get("contact_name", "").strip() email = request.form.get("email", "").strip() phone = request.form.get("phone", "").strip() status = request.form.get("status", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not company_name: errors.append("Company name is required.") if not status: errors.append("Status is required.") if errors: cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) client = cursor.fetchone() conn.close() return render_template("clients/edit.html", client=client, errors=errors) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE clients SET company_name = %s, contact_name = %s, email = %s, phone = %s, status = %s, notes = %s WHERE id = %s """, ( company_name, contact_name or None, email or None, phone or None, status, notes or None, client_id )) conn.commit() conn.close() return redirect("/clients") cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) client = cursor.fetchone() conn.close() if not client: return "Client not found", 404 return render_template("clients/edit.html", client=client, errors=[]) @app.route("/services") def services(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT s.*, c.client_code, c.company_name FROM services s JOIN clients c ON s.client_id = c.id ORDER BY s.id DESC """) services = cursor.fetchall() conn.close() return render_template("services/list.html", services=services) @app.route("/services/new", methods=["GET", "POST"]) def new_service(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form["client_id"] service_name = request.form["service_name"] service_type = request.form["service_type"] billing_cycle = request.form["billing_cycle"] currency_code = request.form["currency_code"] recurring_amount = request.form["recurring_amount"] status = request.form["status"] start_date = request.form["start_date"] or None description = request.form["description"] cursor.execute("SELECT MAX(id) AS last_id FROM services") result = cursor.fetchone() last_number = result["last_id"] if result["last_id"] else 0 service_code = generate_service_code(service_name, last_number) insert_cursor = conn.cursor() insert_cursor.execute( """ INSERT INTO services ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) ) conn.commit() conn.close() return redirect("/services") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() return render_template("services/new.html", clients=clients) @app.route("/invoices") def invoices(): refresh_overdue_invoices() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, c.client_code, c.company_name FROM invoices i JOIN clients c ON i.client_id = c.id ORDER BY i.id DESC """) invoices = cursor.fetchall() conn.close() return render_template("invoices/list.html", invoices=invoices) @app.route("/invoices/new", methods=["GET", "POST"]) def new_invoice(): 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() currency_code = request.form.get("currency_code", "").strip() total_amount = request.form.get("total_amount", "").strip() due_at = request.form.get("due_at", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not service_id: errors.append("Service is required.") if not currency_code: errors.append("Currency is required.") if not total_amount: errors.append("Total amount is required.") if not due_at: errors.append("Due date is required.") if not errors: try: amount_value = float(total_amount) if amount_value <= 0: errors.append("Total amount must be greater than zero.") except ValueError: errors.append("Total amount 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() form_data = { "client_id": client_id, "service_id": service_id, "currency_code": currency_code, "total_amount": total_amount, "due_at": due_at, "notes": notes, } return render_template( "invoices/new.html", clients=clients, services=services, errors=errors, form_data=form_data, ) cursor.execute("SELECT MAX(id) AS last_id FROM invoices") result = cursor.fetchone() number = (result["last_id"] or 0) + 1 invoice_number = f"INV-{number:04d}" insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO invoices ( client_id, service_id, invoice_number, currency_code, total_amount, subtotal_amount, issued_at, due_at, status, notes ) VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) """, ( client_id, service_id, invoice_number, currency_code, total_amount, total_amount, due_at, notes )) conn.commit() conn.close() return redirect("/invoices") 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( "invoices/new.html", clients=clients, services=services, errors=[], form_data={}, ) @app.route("/payments") def payments(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT p.*, i.invoice_number, c.client_code, c.company_name FROM payments p JOIN invoices i ON p.invoice_id = i.id JOIN clients c ON p.client_id = c.id ORDER BY p.id DESC """) payments = cursor.fetchall() conn.close() return render_template("payments/list.html", payments=payments) @app.route("/payments/new", methods=["GET", "POST"]) def new_payment(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": invoice_id = request.form.get("invoice_id", "").strip() payment_method = request.form.get("payment_method", "").strip() payment_currency = request.form.get("payment_currency", "").strip() payment_amount = request.form.get("payment_amount", "").strip() cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() reference = request.form.get("reference", "").strip() sender_name = request.form.get("sender_name", "").strip() txid = request.form.get("txid", "").strip() wallet_address = request.form.get("wallet_address", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not invoice_id: errors.append("Invoice is required.") if not payment_method: errors.append("Payment method is required.") if not payment_currency: errors.append("Payment currency is required.") if not payment_amount: errors.append("Payment amount is required.") if not cad_value_at_payment: errors.append("CAD value at payment is required.") amount_value = None if not errors: try: amount_value = float(payment_amount) if amount_value <= 0: errors.append("Payment amount must be greater than zero.") except ValueError: errors.append("Payment amount must be a valid number.") try: cad_value = float(cad_value_at_payment) if cad_value < 0: errors.append("CAD value at payment cannot be negative.") except ValueError: errors.append("CAD value at payment must be a valid number.") if errors: cursor.execute(""" SELECT i.id, i.invoice_number, c.client_code, c.company_name, i.total_amount, i.amount_paid, i.currency_code FROM invoices i JOIN clients c ON i.client_id = c.id ORDER BY i.id DESC """) invoices = cursor.fetchall() conn.close() form_data = { "invoice_id": invoice_id, "payment_method": payment_method, "payment_currency": payment_currency, "payment_amount": payment_amount, "cad_value_at_payment": cad_value_at_payment, "reference": reference, "sender_name": sender_name, "txid": txid, "wallet_address": wallet_address, "notes": notes, } return render_template( "payments/new.html", invoices=invoices, errors=errors, form_data=form_data, ) cursor.execute("SELECT client_id, total_amount, amount_paid FROM invoices WHERE id = %s", (invoice_id,)) invoice = cursor.fetchone() if not invoice: conn.close() return "Invoice not found", 404 client_id = invoice["client_id"] new_amount_paid = float(invoice["amount_paid"]) + amount_value if new_amount_paid >= float(invoice["total_amount"]): new_status = "paid" elif new_amount_paid > 0: new_status = "partial" else: new_status = "pending" insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO payments ( invoice_id, client_id, payment_method, payment_currency, payment_amount, cad_value_at_payment, reference, sender_name, txid, wallet_address, payment_status, received_at, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) """, ( invoice_id, client_id, payment_method, payment_currency, payment_amount, cad_value_at_payment, reference or None, sender_name or None, txid or None, wallet_address or None, notes or None )) update_cursor = conn.cursor() if new_status == "paid": update_cursor.execute(""" UPDATE invoices SET amount_paid = %s, status = %s, paid_at = UTC_TIMESTAMP() WHERE id = %s """, (new_amount_paid, new_status, invoice_id)) else: update_cursor.execute(""" UPDATE invoices SET amount_paid = %s, status = %s WHERE id = %s """, (new_amount_paid, new_status, invoice_id)) conn.commit() conn.close() return redirect("/payments") cursor.execute(""" SELECT i.id, i.invoice_number, c.client_code, c.company_name, i.total_amount, i.amount_paid, i.currency_code FROM invoices i JOIN clients c ON i.client_id = c.id ORDER BY i.id DESC """) invoices = cursor.fetchall() conn.close() return render_template( "payments/new.html", invoices=invoices, errors=[], form_data={}, ) if __name__ == "__main__": app.run(host="0.0.0.0", port=5050, debug=True)