diff --git a/backend/app.py b/backend/app.py
index d01f0a6..b634fc9 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -4,7 +4,6 @@ from utils import generate_client_code, generate_service_code
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from decimal import Decimal, InvalidOperation
-import os
app = Flask(
__name__,
@@ -14,19 +13,18 @@ app = Flask(
LOCAL_TZ = ZoneInfo("America/Toronto")
-# load version
def load_version():
try:
- with open("../VERSION") as f:
+ with open("/home/def/otb_billing/VERSION", "r") as f:
return f.read().strip()
- except:
+ except Exception:
return "unknown"
APP_VERSION = load_version()
@app.context_processor
def inject_version():
- return dict(app_version=APP_VERSION)
+ return {"app_version": APP_VERSION}
def fmt_local(dt_value):
if not dt_value:
@@ -96,9 +94,9 @@ def index():
outstanding_invoices = cursor.fetchone()["outstanding_invoices"]
cursor.execute("""
- SELECT COALESCE(SUM(cad_value_at_payment),0) AS revenue_received
+ SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received
FROM payments
- WHERE payment_status='confirmed'
+ WHERE payment_status = 'confirmed'
""")
revenue_received = cursor.fetchone()["revenue_received"]
@@ -120,15 +118,522 @@ def dbtest():
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])}
-"""
+ 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}"
-# rest of routes remain unchanged
+@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)
diff --git a/templates/clients/edit.html b/templates/clients/edit.html
index 0b24e4f..1f7a39e 100644
--- a/templates/clients/edit.html
+++ b/templates/clients/edit.html
@@ -70,5 +70,6 @@ Notes
+{% include "footer.html" %}