diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index 0ccf06f..ba42072 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -83,3 +83,12 @@ Infrastructure:
- Portal domain: portal.outsidethebox.top
- Billing admin: otb-billing.outsidethebox.top
+
+## v0.3.0 - 2026-05-03
+- Portal onboarding flow upgraded
+- Email invites now include clickable activation link
+- /portal/set-password now supports direct email+code login
+- Auto session creation from invite link
+- Improved UX: no manual code entry required
+- Portal onboarding now production-ready
+
diff --git a/README.md b/README.md
index 6af9316..58a79dd 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,8 @@
+## v0.3.0 (2026-05-03)
+- Clickable portal invite links
+- Direct account activation from email
+- Improved onboarding UX
+
## v0.6.2 - 2026-04-23
### Changes
diff --git a/VERSION b/VERSION
index 45964c6..268b033 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v0.6.2
+v0.3.0
diff --git a/backend/app-backups/app.py.bak_accounting_builder b/backend/app-backups/app.py.bak_accounting_builder
deleted file mode 100644
index 8153fb9..0000000
--- a/backend/app-backups/app.py.bak_accounting_builder
+++ /dev/null
@@ -1,2591 +0,0 @@
-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 zoneinfo import ZoneInfo
-from decimal import Decimal, InvalidOperation
-from email.message import EmailMessage
-
-from io import BytesIO, StringIO
-import csv
-import zipfile
-import smtplib
-from reportlab.lib.pagesizes import letter
-from reportlab.pdfgen import canvas
-from reportlab.lib.utils import ImageReader
-
-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}
-
-@app.context_processor
-def inject_app_settings():
- return {"app_settings": get_app_settings()}
-
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-
-def generate_invoice_number():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT invoice_number
- FROM invoices
- WHERE invoice_number IS NOT NULL
- AND invoice_number LIKE 'INV-%'
- ORDER BY id DESC
- LIMIT 1
- """)
- row = cursor.fetchone()
- conn.close()
-
- if not row or not row.get("invoice_number"):
- return "INV-0001"
-
- invoice_number = str(row["invoice_number"]).strip()
-
- try:
- number = int(invoice_number.split("-")[1])
- except (IndexError, ValueError):
- return "INV-0001"
-
- return f"INV-{number + 1:04d}"
-
-
-APP_SETTINGS_DEFAULTS = {
- "business_name": "OTB Billing",
- "business_tagline": "By a contractor, for contractors",
- "business_logo_url": "",
- "business_email": "",
- "business_phone": "",
- "business_address": "",
- "business_website": "",
- "tax_label": "HST",
- "tax_rate": "13.00",
- "tax_number": "",
- "business_number": "",
- "default_currency": "CAD",
- "report_frequency": "monthly",
- "invoice_footer": "",
- "payment_terms": "",
- "local_country": "Canada",
- "apply_local_tax_only": "1",
- "smtp_host": "",
- "smtp_port": "587",
- "smtp_user": "",
- "smtp_pass": "",
- "smtp_from_email": "",
- "smtp_from_name": "",
- "smtp_use_tls": "1",
- "smtp_use_ssl": "0",
- "report_delivery_email": "",
-}
-
-def ensure_app_settings_table():
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS app_settings (
- setting_key VARCHAR(100) NOT NULL PRIMARY KEY,
- setting_value TEXT NULL,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- )
- """)
- conn.commit()
- conn.close()
-
-def get_app_settings():
- ensure_app_settings_table()
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT setting_key, setting_value
- FROM app_settings
- """)
- rows = cursor.fetchall()
- conn.close()
-
- settings = dict(APP_SETTINGS_DEFAULTS)
- for row in rows:
- settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else ""
-
- return settings
-
-def save_app_settings(form_data):
- ensure_app_settings_table()
- conn = get_db_connection()
- cursor = conn.cursor()
-
- for key in APP_SETTINGS_DEFAULTS.keys():
- if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}:
- value = "1" if form_data.get(key) else "0"
- else:
- value = (form_data.get(key) or "").strip()
-
- cursor.execute("""
- INSERT INTO app_settings (setting_key, setting_value)
- VALUES (%s, %s)
- ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)
- """, (key, value))
-
- 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)
-
-
-
-
-def get_report_period_bounds(frequency):
- now_local = datetime.now(LOCAL_TZ)
-
- if frequency == "yearly":
- start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
- label = f"{now_local.year}"
- elif frequency == "quarterly":
- quarter = ((now_local.month - 1) // 3) + 1
- start_month = (quarter - 1) * 3 + 1
- start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0)
- label = f"Q{quarter} {now_local.year}"
- else:
- start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
- label = now_local.strftime("%B %Y")
-
- start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None)
- end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None)
-
- return start_utc, end_utc, label
-
-def get_revenue_report_data():
- settings = get_app_settings()
- frequency = (settings.get("report_frequency") or "monthly").strip().lower()
- if frequency not in {"monthly", "quarterly", "yearly"}:
- frequency = "monthly"
-
- start_utc, end_utc, label = get_report_period_bounds(frequency)
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected
- FROM payments
- WHERE payment_status = 'confirmed'
- AND received_at >= %s
- AND received_at <= %s
- """, (start_utc, end_utc))
- collected_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS invoice_count,
- COALESCE(SUM(total_amount), 0) AS invoiced
- FROM invoices
- WHERE issued_at >= %s
- AND issued_at <= %s
- """, (start_utc, end_utc))
- invoiced_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS overdue_count,
- COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance
- FROM invoices
- WHERE status = 'overdue'
- """)
- overdue_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS outstanding_count,
- COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance
- FROM invoices
- WHERE status IN ('pending', 'partial', 'overdue')
- """)
- outstanding_row = cursor.fetchone()
-
- conn.close()
-
- return {
- "frequency": frequency,
- "period_label": label,
- "period_start": start_utc.isoformat(sep=" "),
- "period_end": end_utc.isoformat(sep=" "),
- "collected_cad": str(to_decimal(collected_row["collected"])),
- "invoice_count": int(invoiced_row["invoice_count"] or 0),
- "invoiced_total": str(to_decimal(invoiced_row["invoiced"])),
- "overdue_count": int(overdue_row["overdue_count"] or 0),
- "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])),
- "outstanding_count": int(outstanding_row["outstanding_count"] or 0),
- "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])),
- }
-
-
-def ensure_email_log_table():
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS email_log (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- email_type VARCHAR(50) NOT NULL,
- invoice_id INT UNSIGNED NULL,
- recipient_email VARCHAR(255) NOT NULL,
- subject VARCHAR(255) NOT NULL,
- status VARCHAR(20) NOT NULL,
- error_message TEXT NULL,
- sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- KEY idx_email_log_invoice_id (invoice_id),
- KEY idx_email_log_type (email_type),
- KEY idx_email_log_sent_at (sent_at)
- )
- """)
- conn.commit()
- conn.close()
-
-
-def log_email_event(email_type, recipient_email, subject, status, invoice_id=None, error_message=None):
- ensure_email_log_table()
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- INSERT INTO email_log
- (email_type, invoice_id, recipient_email, subject, status, error_message)
- VALUES (%s, %s, %s, %s, %s, %s)
- """, (
- email_type,
- invoice_id,
- recipient_email,
- subject,
- status,
- error_message
- ))
- conn.commit()
- conn.close()
-
-
-
-def send_configured_email(to_email, subject, body, attachments=None, email_type="system_email", invoice_id=None):
- settings = get_app_settings()
-
- smtp_host = (settings.get("smtp_host") or "").strip()
- smtp_port = int((settings.get("smtp_port") or "587").strip() or "587")
- smtp_user = (settings.get("smtp_user") or "").strip()
- smtp_pass = (settings.get("smtp_pass") or "").strip()
- from_email = (settings.get("smtp_from_email") or settings.get("business_email") or "").strip()
- from_name = (settings.get("smtp_from_name") or settings.get("business_name") or "").strip()
- use_tls = (settings.get("smtp_use_tls") or "0") == "1"
- use_ssl = (settings.get("smtp_use_ssl") or "0") == "1"
-
- if not smtp_host:
- raise ValueError("SMTP host is not configured.")
- if not from_email:
- raise ValueError("From email is not configured.")
- if not to_email:
- raise ValueError("Recipient email is missing.")
-
- msg = EmailMessage()
- msg["Subject"] = subject
- msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
- msg["To"] = to_email
- msg.set_content(body)
-
- for attachment in attachments or []:
- filename = attachment["filename"]
- mime_type = attachment["mime_type"]
- data = attachment["data"]
- maintype, subtype = mime_type.split("/", 1)
- msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
-
- try:
- if use_ssl:
- with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30) as server:
- if smtp_user:
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
- else:
- with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
- server.ehlo()
- if use_tls:
- server.starttls()
- server.ehlo()
- if smtp_user:
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
-
- log_email_event(email_type, to_email, subject, "sent", invoice_id=invoice_id, error_message=None)
- except Exception as e:
- log_email_event(email_type, to_email, subject, "failed", invoice_id=invoice_id, error_message=str(e))
- raise
-
-@app.route("/settings", methods=["GET", "POST"])
-def settings():
- ensure_app_settings_table()
-
- if request.method == "POST":
- save_app_settings(request.form)
- return redirect("/settings")
-
- settings = get_app_settings()
- return render_template("settings.html", settings=settings)
-
-
-
-
-@app.route("/reports/accounting-package.zip")
-def accounting_package_zip():
- package_bytes, filename = build_accounting_package_bytes()
- return send_file(
- BytesIO(package_bytes),
- mimetype="application/zip",
- as_attachment=True,
- download_name=filename
- )
-
-@app.route("/reports/revenue")
-def revenue_report():
- report = get_revenue_report_data()
- return render_template("reports/revenue.html", report=report)
-
-@app.route("/reports/revenue.json")
-def revenue_report_json():
- report = get_revenue_report_data()
- return jsonify(report)
-
-@app.route("/reports/revenue/print")
-def revenue_report_print():
- report = get_revenue_report_data()
- return render_template("reports/revenue_print.html", report=report)
-
-
-
-@app.route("/invoices/email/", methods=["POST"])
-def email_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
- conn.close()
-
- if not invoice:
- return "Invoice not found", 404
-
- recipient = (invoice.get("email") or "").strip()
- if not recipient:
- return "Client email is missing for this invoice.", 400
-
- settings = get_app_settings()
-
- with app.test_client() as client:
- pdf_response = client.get(f"/invoices/pdf/{invoice_id}")
- if pdf_response.status_code != 200:
- return "Could not generate invoice PDF for email.", 500
-
- pdf_bytes = pdf_response.data
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
- subject = f"Invoice {invoice['invoice_number']} from {settings.get('business_name') or 'OTB Billing'}"
- body = (
- f"Hello {invoice.get('contact_name') or invoice.get('company_name') or ''},\n\n"
- f"Please find attached invoice {invoice['invoice_number']}.\n"
- f"Total: {to_decimal(invoice.get('total_amount')):.2f} {invoice.get('currency_code', 'CAD')}\n"
- f"Remaining: {remaining:.2f} {invoice.get('currency_code', 'CAD')}\n"
- f"Due: {fmt_local(invoice.get('due_at'))}\n\n"
- f"Thank you,\n"
- f"{settings.get('business_name') or 'OTB Billing'}"
- )
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- email_type="invoice",
- invoice_id=invoice_id,
- attachments=[{
- "filename": f"{invoice['invoice_number']}.pdf",
- "mime_type": "application/pdf",
- "data": pdf_bytes,
- }]
- )
- return redirect(f"/invoices/view/{invoice_id}?email_sent=1")
- except Exception:
- return redirect(f"/invoices/view/{invoice_id}?email_failed=1")
-
-
-@app.route("/reports/revenue/email", methods=["POST"])
-def email_revenue_report_json():
- settings = get_app_settings()
- recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip()
- if not recipient:
- return "Report delivery email is not configured.", 400
-
- with app.test_client() as client:
- json_response = client.get("/reports/revenue.json")
- if json_response.status_code != 200:
- return "Could not generate revenue report JSON.", 500
-
- report = get_revenue_report_data()
- subject = f"Revenue Report {report.get('period_label', '')} from {settings.get('business_name') or 'OTB Billing'}"
- body = (
- f"Attached is the revenue report JSON for {report.get('period_label', '')}.\n\n"
- f"Frequency: {report.get('frequency', '')}\n"
- f"Collected CAD: {report.get('collected_cad', '')}\n"
- f"Invoices Issued: {report.get('invoice_count', '')}\n"
- )
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- email_type="revenue_report",
- attachments=[{
- "filename": "revenue_report.json",
- "mime_type": "application/json",
- "data": json_response.data,
- }]
- )
- return redirect("/reports/revenue?email_sent=1")
- except Exception:
- return redirect("/reports/revenue?email_failed=1")
-
-
-@app.route("/reports/accounting-package/email", methods=["POST"])
-def email_accounting_package():
- settings = get_app_settings()
- recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip()
- if not recipient:
- return "Report delivery email is not configured.", 400
-
- with app.test_client() as client:
- zip_response = client.get("/reports/accounting-package.zip")
- if zip_response.status_code != 200:
- return "Could not generate accounting package ZIP.", 500
-
- subject = f"Accounting Package from {settings.get('business_name') or 'OTB Billing'}"
- body = "Attached is the latest accounting package export."
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- email_type="accounting_package",
- attachments=[{
- "filename": "accounting_package.zip",
- "mime_type": "application/zip",
- "data": zip_response.data,
- }]
- )
- return redirect("/?pkg_email=1")
- except Exception:
- return redirect("/?pkg_email_failed=1")
-
-@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/export.csv")
-def export_clients_csv():
- 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
- """)
- 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"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-
-
-
-
-
-@app.route("/invoices/export.csv")
-def export_invoices_csv():
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.id,
- i.invoice_number,
- i.client_id,
- c.client_code,
- c.company_name,
- i.service_id,
- i.currency_code,
- i.subtotal_amount,
- i.tax_amount,
- i.total_amount,
- i.amount_paid,
- i.status,
- i.issued_at,
- i.due_at,
- i.paid_at,
- i.notes,
- i.created_at,
- i.updated_at
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "invoice_number",
- "client_id",
- "client_code",
- "company_name",
- "service_id",
- "currency_code",
- "subtotal_amount",
- "tax_amount",
- "total_amount",
- "amount_paid",
- "status",
- "issued_at",
- "due_at",
- "paid_at",
- "notes",
- "created_at",
- "updated_at",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("invoice_number", ""),
- r.get("client_id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("service_id", ""),
- r.get("currency_code", ""),
- r.get("subtotal_amount", ""),
- r.get("tax_amount", ""),
- r.get("total_amount", ""),
- r.get("amount_paid", ""),
- r.get("status", ""),
- r.get("issued_at", ""),
- r.get("due_at", ""),
- r.get("paid_at", ""),
- r.get("notes", ""),
- r.get("created_at", ""),
- r.get("updated_at", ""),
- ])
-
- filename = "invoices"
- if start_date or end_date or status or client_id or limit_count:
- filename += "_filtered"
- filename += ".csv"
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = f"attachment; filename={filename}"
- return response
-
-
-@app.route("/invoices/export-pdf.zip")
-def export_invoices_pdf_zip():
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
- conn.close()
-
- settings = get_app_settings()
-
- def build_invoice_pdf_bytes(invoice, settings):
- buffer = BytesIO()
- pdf = canvas.Canvas(buffer, pagesize=letter)
- width, height = letter
-
- left = 50
- right = 560
- y = height - 50
-
- def money(value, currency="CAD"):
- return f"{to_decimal(value):.2f} {currency}"
-
- pdf.setTitle(f"Invoice {invoice['invoice_number']}")
-
- logo_url = (settings.get("business_logo_url") or "").strip()
- if logo_url.startswith("/static/"):
- local_logo_path = "/home/def/otb_billing" + logo_url
- try:
- pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto')
- except Exception:
- pass
-
- pdf.setFont("Helvetica-Bold", 22)
- pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}")
-
- pdf.setFont("Helvetica-Bold", 14)
- pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing")
- y -= 18
- pdf.setFont("Helvetica", 12)
- pdf.drawRightString(right, y, settings.get("business_tagline") or "")
- y -= 15
-
- right_lines = [
- settings.get("business_address", ""),
- settings.get("business_email", ""),
- settings.get("business_phone", ""),
- settings.get("business_website", ""),
- ]
- for item in right_lines:
- if item:
- pdf.drawRightString(right, y, item[:80])
- y -= 14
-
- y -= 10
-
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, "Status:")
- pdf.setFont("Helvetica", 12)
- pdf.drawString(left + 45, y, str(invoice["status"]).upper())
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Bill To")
- y -= 20
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, invoice["company_name"] or "")
- y -= 16
- pdf.setFont("Helvetica", 11)
- if invoice.get("contact_name"):
- pdf.drawString(left, y, str(invoice["contact_name"]))
- y -= 15
- if invoice.get("email"):
- pdf.drawString(left, y, str(invoice["email"]))
- y -= 15
- if invoice.get("phone"):
- pdf.drawString(left, y, str(invoice["phone"]))
- y -= 15
- pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Invoice Details")
- y -= 20
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}")
- y -= 15
- pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}")
- y -= 15
- if invoice.get("paid_at"):
- pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Service Code")
- pdf.drawString(180, y, "Service")
- pdf.drawString(330, y, "Description")
- pdf.drawRightString(right, y, "Total")
- y -= 14
- pdf.line(left, y, right, y)
- y -= 18
-
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, str(invoice.get("service_code") or "-"))
- pdf.drawString(180, y, str(invoice.get("service_name") or "-"))
- pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28])
- pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD")))
- y -= 28
-
- totals_x_label = 360
- totals_x_value = right
-
- totals = [
- ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))),
- ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))),
- ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))),
- ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))),
- ]
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
-
- for label, value in totals:
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, label)
- pdf.setFont("Helvetica", 11)
- pdf.drawRightString(totals_x_value, y, value)
- y -= 18
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, "Remaining")
- pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}")
- y -= 25
-
- if settings.get("tax_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}")
- y -= 14
-
- if settings.get("business_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}")
- y -= 14
-
- if settings.get("payment_terms"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Payment Terms")
- y -= 15
- pdf.setFont("Helvetica", 10)
- terms = settings.get("payment_terms", "")
- for chunk_start in range(0, len(terms), 90):
- line_text = terms[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- if settings.get("invoice_footer"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Footer")
- y -= 15
- pdf.setFont("Helvetica", 10)
- footer = settings.get("invoice_footer", "")
- for chunk_start in range(0, len(footer), 90):
- line_text = footer[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- pdf.showPage()
- pdf.save()
- buffer.seek(0)
- return buffer.getvalue()
-
- zip_buffer = BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
- for invoice in invoices:
- pdf_bytes = build_invoice_pdf_bytes(invoice, settings)
- zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes)
-
- zip_buffer.seek(0)
-
- filename = "invoices_export"
- if start_date:
- filename += f"_{start_date}"
- if end_date:
- filename += f"_to_{end_date}"
- if status:
- filename += f"_{status}"
- if client_id:
- filename += f"_client_{client_id}"
- if limit_count:
- filename += f"_limit_{limit_count}"
- filename += ".zip"
-
- return send_file(
- zip_buffer,
- mimetype="application/zip",
- as_attachment=True,
- download_name=filename
- )
-
-
-@app.route("/invoices/print")
-def print_invoices():
- refresh_overdue_invoices()
-
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
- conn.close()
-
- settings = get_app_settings()
-
- filters = {
- "start_date": start_date,
- "end_date": end_date,
- "status": status,
- "client_id": client_id,
- "limit": limit_count,
- }
-
- return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters)
-
-@app.route("/invoices")
-def invoices():
- refresh_overdue_invoices()
-
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id DESC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- ORDER BY company_name ASC
- """)
- clients = cursor.fetchall()
-
- conn.close()
-
- filters = {
- "start_date": start_date,
- "end_date": end_date,
- "status": status,
- "client_id": client_id,
- "limit": limit_count,
- }
-
- return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients)
-
-@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,
- )
-
- invoice_number = generate_invoice_number()
-
- 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("/invoices/pdf/")
-def invoice_pdf(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- conn.close()
-
- settings = get_app_settings()
-
- buffer = BytesIO()
- pdf = canvas.Canvas(buffer, pagesize=letter)
- width, height = letter
-
- left = 50
- right = 560
- y = height - 50
-
- def draw_line(txt, x=left, font="Helvetica", size=11):
- nonlocal y
- pdf.setFont(font, size)
- pdf.drawString(x, y, str(txt) if txt is not None else "")
- y -= 16
-
- def money(value, currency="CAD"):
- return f"{to_decimal(value):.2f} {currency}"
-
- pdf.setTitle(f"Invoice {invoice['invoice_number']}")
-
- logo_url = (settings.get("business_logo_url") or "").strip()
- if logo_url.startswith("/static/"):
- local_logo_path = "/home/def/otb_billing" + logo_url
- try:
- pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto')
- except Exception:
- pass
-
- pdf.setFont("Helvetica-Bold", 22)
- pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}")
-
- pdf.setFont("Helvetica-Bold", 14)
- pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing")
- y -= 18
- pdf.setFont("Helvetica", 12)
- pdf.drawRightString(right, y, settings.get("business_tagline") or "")
- y -= 15
-
- right_lines = [
- settings.get("business_address", ""),
- settings.get("business_email", ""),
- settings.get("business_phone", ""),
- settings.get("business_website", ""),
- ]
- for item in right_lines:
- if item:
- pdf.drawRightString(right, y, item[:80])
- y -= 14
-
- y -= 10
-
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, "Status:")
- pdf.setFont("Helvetica", 12)
- pdf.drawString(left + 45, y, str(invoice["status"]).upper())
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Bill To")
- y -= 20
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, invoice["company_name"] or "")
- y -= 16
- pdf.setFont("Helvetica", 11)
- if invoice.get("contact_name"):
- pdf.drawString(left, y, str(invoice["contact_name"]))
- y -= 15
- if invoice.get("email"):
- pdf.drawString(left, y, str(invoice["email"]))
- y -= 15
- if invoice.get("phone"):
- pdf.drawString(left, y, str(invoice["phone"]))
- y -= 15
- pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Invoice Details")
- y -= 20
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}")
- y -= 15
- pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}")
- y -= 15
- if invoice.get("paid_at"):
- pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Service Code")
- pdf.drawString(180, y, "Service")
- pdf.drawString(330, y, "Description")
- pdf.drawRightString(right, y, "Total")
- y -= 14
- pdf.line(left, y, right, y)
- y -= 18
-
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, str(invoice.get("service_code") or "-"))
- pdf.drawString(180, y, str(invoice.get("service_name") or "-"))
- pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28])
- pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD")))
- y -= 28
-
- totals_x_label = 360
- totals_x_value = right
-
- totals = [
- ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))),
- ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))),
- ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))),
- ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))),
- ]
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
-
- for label, value in totals:
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, label)
- pdf.setFont("Helvetica", 11)
- pdf.drawRightString(totals_x_value, y, value)
- y -= 18
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, "Remaining")
- pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}")
- y -= 25
-
- if settings.get("tax_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}")
- y -= 14
-
- if settings.get("business_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}")
- y -= 14
-
- if settings.get("payment_terms"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Payment Terms")
- y -= 15
- pdf.setFont("Helvetica", 10)
- for chunk_start in range(0, len(settings.get("payment_terms", "")), 90):
- line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- if settings.get("invoice_footer"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Footer")
- y -= 15
- pdf.setFont("Helvetica", 10)
- for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90):
- line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- pdf.showPage()
- pdf.save()
- buffer.seek(0)
-
- return send_file(
- buffer,
- mimetype="application/pdf",
- as_attachment=True,
- download_name=f"{invoice['invoice_number']}.pdf"
- )
-
-
-@app.route("/invoices/view/")
-def view_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- conn.close()
- settings = get_app_settings()
- return render_template("invoices/view.html", invoice=invoice, settings=settings)
-
-
-@app.route("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-
-
-@app.route("/payments/export.csv")
-def export_payments_csv():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT
- p.id,
- p.invoice_id,
- i.invoice_number,
- p.client_id,
- c.client_code,
- c.company_name,
- p.payment_method,
- p.payment_currency,
- p.payment_amount,
- p.cad_value_at_payment,
- p.reference,
- p.sender_name,
- p.txid,
- p.wallet_address,
- p.payment_status,
- p.received_at,
- p.notes
- 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 ASC
- """)
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "invoice_id",
- "invoice_number",
- "client_id",
- "client_code",
- "company_name",
- "payment_method",
- "payment_currency",
- "payment_amount",
- "cad_value_at_payment",
- "reference",
- "sender_name",
- "txid",
- "wallet_address",
- "payment_status",
- "received_at",
- "notes",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("invoice_id", ""),
- r.get("invoice_number", ""),
- r.get("client_id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("payment_method", ""),
- r.get("payment_currency", ""),
- r.get("payment_amount", ""),
- r.get("cad_value_at_payment", ""),
- r.get("reference", ""),
- r.get("sender_name", ""),
- r.get("txid", ""),
- r.get("wallet_address", ""),
- r.get("payment_status", ""),
- r.get("received_at", ""),
- r.get("notes", ""),
- ])
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = "attachment; filename=payments.csv"
- return response
-
-@app.route("/payments")
-def payments():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- p.*,
- i.invoice_number,
- i.status AS invoice_status,
- i.total_amount,
- i.amount_paid,
- i.currency_code AS invoice_currency_code,
- 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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app-backups/app.py.bak_email_log_safe b/backend/app-backups/app.py.bak_email_log_safe
deleted file mode 100644
index 3d8b0b7..0000000
--- a/backend/app-backups/app.py.bak_email_log_safe
+++ /dev/null
@@ -1,2527 +0,0 @@
-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 zoneinfo import ZoneInfo
-from decimal import Decimal, InvalidOperation
-from email.message import EmailMessage
-
-from io import BytesIO, StringIO
-import csv
-import zipfile
-import smtplib
-from reportlab.lib.pagesizes import letter
-from reportlab.pdfgen import canvas
-from reportlab.lib.utils import ImageReader
-
-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}
-
-@app.context_processor
-def inject_app_settings():
- return {"app_settings": get_app_settings()}
-
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-
-def generate_invoice_number():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT invoice_number
- FROM invoices
- WHERE invoice_number IS NOT NULL
- AND invoice_number LIKE 'INV-%'
- ORDER BY id DESC
- LIMIT 1
- """)
- row = cursor.fetchone()
- conn.close()
-
- if not row or not row.get("invoice_number"):
- return "INV-0001"
-
- invoice_number = str(row["invoice_number"]).strip()
-
- try:
- number = int(invoice_number.split("-")[1])
- except (IndexError, ValueError):
- return "INV-0001"
-
- return f"INV-{number + 1:04d}"
-
-
-APP_SETTINGS_DEFAULTS = {
- "business_name": "OTB Billing",
- "business_tagline": "By a contractor, for contractors",
- "business_logo_url": "",
- "business_email": "",
- "business_phone": "",
- "business_address": "",
- "business_website": "",
- "tax_label": "HST",
- "tax_rate": "13.00",
- "tax_number": "",
- "business_number": "",
- "default_currency": "CAD",
- "report_frequency": "monthly",
- "invoice_footer": "",
- "payment_terms": "",
- "local_country": "Canada",
- "apply_local_tax_only": "1",
- "smtp_host": "",
- "smtp_port": "587",
- "smtp_user": "",
- "smtp_pass": "",
- "smtp_from_email": "",
- "smtp_from_name": "",
- "smtp_use_tls": "1",
- "smtp_use_ssl": "0",
- "report_delivery_email": "",
-}
-
-def ensure_app_settings_table():
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS app_settings (
- setting_key VARCHAR(100) NOT NULL PRIMARY KEY,
- setting_value TEXT NULL,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- )
- """)
- conn.commit()
- conn.close()
-
-def get_app_settings():
- ensure_app_settings_table()
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT setting_key, setting_value
- FROM app_settings
- """)
- rows = cursor.fetchall()
- conn.close()
-
- settings = dict(APP_SETTINGS_DEFAULTS)
- for row in rows:
- settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else ""
-
- return settings
-
-def save_app_settings(form_data):
- ensure_app_settings_table()
- conn = get_db_connection()
- cursor = conn.cursor()
-
- for key in APP_SETTINGS_DEFAULTS.keys():
- if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}:
- value = "1" if form_data.get(key) else "0"
- else:
- value = (form_data.get(key) or "").strip()
-
- cursor.execute("""
- INSERT INTO app_settings (setting_key, setting_value)
- VALUES (%s, %s)
- ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)
- """, (key, value))
-
- 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)
-
-
-
-
-def get_report_period_bounds(frequency):
- now_local = datetime.now(LOCAL_TZ)
-
- if frequency == "yearly":
- start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
- label = f"{now_local.year}"
- elif frequency == "quarterly":
- quarter = ((now_local.month - 1) // 3) + 1
- start_month = (quarter - 1) * 3 + 1
- start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0)
- label = f"Q{quarter} {now_local.year}"
- else:
- start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
- label = now_local.strftime("%B %Y")
-
- start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None)
- end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None)
-
- return start_utc, end_utc, label
-
-def get_revenue_report_data():
- settings = get_app_settings()
- frequency = (settings.get("report_frequency") or "monthly").strip().lower()
- if frequency not in {"monthly", "quarterly", "yearly"}:
- frequency = "monthly"
-
- start_utc, end_utc, label = get_report_period_bounds(frequency)
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected
- FROM payments
- WHERE payment_status = 'confirmed'
- AND received_at >= %s
- AND received_at <= %s
- """, (start_utc, end_utc))
- collected_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS invoice_count,
- COALESCE(SUM(total_amount), 0) AS invoiced
- FROM invoices
- WHERE issued_at >= %s
- AND issued_at <= %s
- """, (start_utc, end_utc))
- invoiced_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS overdue_count,
- COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance
- FROM invoices
- WHERE status = 'overdue'
- """)
- overdue_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS outstanding_count,
- COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance
- FROM invoices
- WHERE status IN ('pending', 'partial', 'overdue')
- """)
- outstanding_row = cursor.fetchone()
-
- conn.close()
-
- return {
- "frequency": frequency,
- "period_label": label,
- "period_start": start_utc.isoformat(sep=" "),
- "period_end": end_utc.isoformat(sep=" "),
- "collected_cad": str(to_decimal(collected_row["collected"])),
- "invoice_count": int(invoiced_row["invoice_count"] or 0),
- "invoiced_total": str(to_decimal(invoiced_row["invoiced"])),
- "overdue_count": int(overdue_row["overdue_count"] or 0),
- "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])),
- "outstanding_count": int(outstanding_row["outstanding_count"] or 0),
- "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])),
- }
-
-
-
-def send_configured_email(to_email, subject, body, attachments=None):
- settings = get_app_settings()
-
- smtp_host = (settings.get("smtp_host") or "").strip()
- smtp_port = int((settings.get("smtp_port") or "587").strip() or "587")
- smtp_user = (settings.get("smtp_user") or "").strip()
- smtp_pass = (settings.get("smtp_pass") or "").strip()
- from_email = (settings.get("smtp_from_email") or settings.get("business_email") or "").strip()
- from_name = (settings.get("smtp_from_name") or settings.get("business_name") or "").strip()
- use_tls = (settings.get("smtp_use_tls") or "0") == "1"
- use_ssl = (settings.get("smtp_use_ssl") or "0") == "1"
-
- if not smtp_host:
- raise ValueError("SMTP host is not configured.")
- if not from_email:
- raise ValueError("From email is not configured.")
- if not to_email:
- raise ValueError("Recipient email is missing.")
-
- msg = EmailMessage()
- msg["Subject"] = subject
- msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
- msg["To"] = to_email
- msg.set_content(body)
-
- for attachment in attachments or []:
- filename = attachment["filename"]
- mime_type = attachment["mime_type"]
- data = attachment["data"]
- maintype, subtype = mime_type.split("/", 1)
- msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
-
- if use_ssl:
- with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30) as server:
- if smtp_user:
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
- else:
- with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
- server.ehlo()
- if use_tls:
- server.starttls()
- server.ehlo()
- if smtp_user:
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
-
-@app.route("/settings", methods=["GET", "POST"])
-def settings():
- ensure_app_settings_table()
-
- if request.method == "POST":
- save_app_settings(request.form)
- return redirect("/settings")
-
- settings = get_app_settings()
- return render_template("settings.html", settings=settings)
-
-
-@app.route("/reports/revenue")
-def revenue_report():
- report = get_revenue_report_data()
- return render_template("reports/revenue.html", report=report)
-
-@app.route("/reports/revenue.json")
-def revenue_report_json():
- report = get_revenue_report_data()
- return jsonify(report)
-
-@app.route("/reports/revenue/print")
-def revenue_report_print():
- report = get_revenue_report_data()
- return render_template("reports/revenue_print.html", report=report)
-
-
-
-@app.route("/invoices/email/", methods=["POST"])
-def email_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
- conn.close()
-
- if not invoice:
- return "Invoice not found", 404
-
- recipient = (invoice.get("email") or "").strip()
- if not recipient:
- return "Client email is missing for this invoice.", 400
-
- settings = get_app_settings()
-
- with app.test_client() as client:
- pdf_response = client.get(f"/invoices/pdf/{invoice_id}")
- if pdf_response.status_code != 200:
- return "Could not generate invoice PDF for email.", 500
-
- pdf_bytes = pdf_response.data
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
- subject = f"Invoice {invoice['invoice_number']} from {settings.get('business_name') or 'OTB Billing'}"
- body = (
- f"Hello {invoice.get('contact_name') or invoice.get('company_name') or ''},\n\n"
- f"Please find attached invoice {invoice['invoice_number']}.\n"
- f"Total: {to_decimal(invoice.get('total_amount')):.2f} {invoice.get('currency_code', 'CAD')}\n"
- f"Remaining: {remaining:.2f} {invoice.get('currency_code', 'CAD')}\n"
- f"Due: {fmt_local(invoice.get('due_at'))}\n\n"
- f"Thank you,\n"
- f"{settings.get('business_name') or 'OTB Billing'}"
- )
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- attachments=[{
- "filename": f"{invoice['invoice_number']}.pdf",
- "mime_type": "application/pdf",
- "data": pdf_bytes,
- }]
- )
- return redirect(f"/invoices/view/{invoice_id}?email_sent=1")
- except Exception:
- return redirect(f"/invoices/view/{invoice_id}?email_failed=1")
-
-
-@app.route("/reports/revenue/email", methods=["POST"])
-def email_revenue_report_json():
- settings = get_app_settings()
- recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip()
- if not recipient:
- return "Report delivery email is not configured.", 400
-
- with app.test_client() as client:
- json_response = client.get("/reports/revenue.json")
- if json_response.status_code != 200:
- return "Could not generate revenue report JSON.", 500
-
- report = get_revenue_report_data()
- subject = f"Revenue Report {report.get('period_label', '')} from {settings.get('business_name') or 'OTB Billing'}"
- body = (
- f"Attached is the revenue report JSON for {report.get('period_label', '')}.\n\n"
- f"Frequency: {report.get('frequency', '')}\n"
- f"Collected CAD: {report.get('collected_cad', '')}\n"
- f"Invoices Issued: {report.get('invoice_count', '')}\n"
- )
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- attachments=[{
- "filename": "revenue_report.json",
- "mime_type": "application/json",
- "data": json_response.data,
- }]
- )
- return redirect("/reports/revenue?email_sent=1")
- except Exception:
- return redirect("/reports/revenue?email_failed=1")
-
-
-@app.route("/reports/accounting-package/email", methods=["POST"])
-def email_accounting_package():
- settings = get_app_settings()
- recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip()
- if not recipient:
- return "Report delivery email is not configured.", 400
-
- with app.test_client() as client:
- zip_response = client.get("/reports/accounting-package.zip")
- if zip_response.status_code != 200:
- return "Could not generate accounting package ZIP.", 500
-
- subject = f"Accounting Package from {settings.get('business_name') or 'OTB Billing'}"
- body = "Attached is the latest accounting package export."
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- attachments=[{
- "filename": "accounting_package.zip",
- "mime_type": "application/zip",
- "data": zip_response.data,
- }]
- )
- return redirect("/?pkg_email=1")
- except Exception:
- return redirect("/?pkg_email_failed=1")
-
-@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/export.csv")
-def export_clients_csv():
- 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
- """)
- 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"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-
-
-
-
-
-@app.route("/invoices/export.csv")
-def export_invoices_csv():
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.id,
- i.invoice_number,
- i.client_id,
- c.client_code,
- c.company_name,
- i.service_id,
- i.currency_code,
- i.subtotal_amount,
- i.tax_amount,
- i.total_amount,
- i.amount_paid,
- i.status,
- i.issued_at,
- i.due_at,
- i.paid_at,
- i.notes,
- i.created_at,
- i.updated_at
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "invoice_number",
- "client_id",
- "client_code",
- "company_name",
- "service_id",
- "currency_code",
- "subtotal_amount",
- "tax_amount",
- "total_amount",
- "amount_paid",
- "status",
- "issued_at",
- "due_at",
- "paid_at",
- "notes",
- "created_at",
- "updated_at",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("invoice_number", ""),
- r.get("client_id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("service_id", ""),
- r.get("currency_code", ""),
- r.get("subtotal_amount", ""),
- r.get("tax_amount", ""),
- r.get("total_amount", ""),
- r.get("amount_paid", ""),
- r.get("status", ""),
- r.get("issued_at", ""),
- r.get("due_at", ""),
- r.get("paid_at", ""),
- r.get("notes", ""),
- r.get("created_at", ""),
- r.get("updated_at", ""),
- ])
-
- filename = "invoices"
- if start_date or end_date or status or client_id or limit_count:
- filename += "_filtered"
- filename += ".csv"
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = f"attachment; filename={filename}"
- return response
-
-
-@app.route("/invoices/export-pdf.zip")
-def export_invoices_pdf_zip():
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
- conn.close()
-
- settings = get_app_settings()
-
- def build_invoice_pdf_bytes(invoice, settings):
- buffer = BytesIO()
- pdf = canvas.Canvas(buffer, pagesize=letter)
- width, height = letter
-
- left = 50
- right = 560
- y = height - 50
-
- def money(value, currency="CAD"):
- return f"{to_decimal(value):.2f} {currency}"
-
- pdf.setTitle(f"Invoice {invoice['invoice_number']}")
-
- logo_url = (settings.get("business_logo_url") or "").strip()
- if logo_url.startswith("/static/"):
- local_logo_path = "/home/def/otb_billing" + logo_url
- try:
- pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto')
- except Exception:
- pass
-
- pdf.setFont("Helvetica-Bold", 22)
- pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}")
-
- pdf.setFont("Helvetica-Bold", 14)
- pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing")
- y -= 18
- pdf.setFont("Helvetica", 12)
- pdf.drawRightString(right, y, settings.get("business_tagline") or "")
- y -= 15
-
- right_lines = [
- settings.get("business_address", ""),
- settings.get("business_email", ""),
- settings.get("business_phone", ""),
- settings.get("business_website", ""),
- ]
- for item in right_lines:
- if item:
- pdf.drawRightString(right, y, item[:80])
- y -= 14
-
- y -= 10
-
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, "Status:")
- pdf.setFont("Helvetica", 12)
- pdf.drawString(left + 45, y, str(invoice["status"]).upper())
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Bill To")
- y -= 20
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, invoice["company_name"] or "")
- y -= 16
- pdf.setFont("Helvetica", 11)
- if invoice.get("contact_name"):
- pdf.drawString(left, y, str(invoice["contact_name"]))
- y -= 15
- if invoice.get("email"):
- pdf.drawString(left, y, str(invoice["email"]))
- y -= 15
- if invoice.get("phone"):
- pdf.drawString(left, y, str(invoice["phone"]))
- y -= 15
- pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Invoice Details")
- y -= 20
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}")
- y -= 15
- pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}")
- y -= 15
- if invoice.get("paid_at"):
- pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Service Code")
- pdf.drawString(180, y, "Service")
- pdf.drawString(330, y, "Description")
- pdf.drawRightString(right, y, "Total")
- y -= 14
- pdf.line(left, y, right, y)
- y -= 18
-
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, str(invoice.get("service_code") or "-"))
- pdf.drawString(180, y, str(invoice.get("service_name") or "-"))
- pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28])
- pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD")))
- y -= 28
-
- totals_x_label = 360
- totals_x_value = right
-
- totals = [
- ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))),
- ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))),
- ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))),
- ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))),
- ]
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
-
- for label, value in totals:
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, label)
- pdf.setFont("Helvetica", 11)
- pdf.drawRightString(totals_x_value, y, value)
- y -= 18
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, "Remaining")
- pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}")
- y -= 25
-
- if settings.get("tax_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}")
- y -= 14
-
- if settings.get("business_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}")
- y -= 14
-
- if settings.get("payment_terms"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Payment Terms")
- y -= 15
- pdf.setFont("Helvetica", 10)
- terms = settings.get("payment_terms", "")
- for chunk_start in range(0, len(terms), 90):
- line_text = terms[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- if settings.get("invoice_footer"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Footer")
- y -= 15
- pdf.setFont("Helvetica", 10)
- footer = settings.get("invoice_footer", "")
- for chunk_start in range(0, len(footer), 90):
- line_text = footer[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- pdf.showPage()
- pdf.save()
- buffer.seek(0)
- return buffer.getvalue()
-
- zip_buffer = BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
- for invoice in invoices:
- pdf_bytes = build_invoice_pdf_bytes(invoice, settings)
- zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes)
-
- zip_buffer.seek(0)
-
- filename = "invoices_export"
- if start_date:
- filename += f"_{start_date}"
- if end_date:
- filename += f"_to_{end_date}"
- if status:
- filename += f"_{status}"
- if client_id:
- filename += f"_client_{client_id}"
- if limit_count:
- filename += f"_limit_{limit_count}"
- filename += ".zip"
-
- return send_file(
- zip_buffer,
- mimetype="application/zip",
- as_attachment=True,
- download_name=filename
- )
-
-
-@app.route("/invoices/print")
-def print_invoices():
- refresh_overdue_invoices()
-
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
- conn.close()
-
- settings = get_app_settings()
-
- filters = {
- "start_date": start_date,
- "end_date": end_date,
- "status": status,
- "client_id": client_id,
- "limit": limit_count,
- }
-
- return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters)
-
-@app.route("/invoices")
-def invoices():
- refresh_overdue_invoices()
-
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id DESC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- ORDER BY company_name ASC
- """)
- clients = cursor.fetchall()
-
- conn.close()
-
- filters = {
- "start_date": start_date,
- "end_date": end_date,
- "status": status,
- "client_id": client_id,
- "limit": limit_count,
- }
-
- return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients)
-
-@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,
- )
-
- invoice_number = generate_invoice_number()
-
- 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("/invoices/pdf/")
-def invoice_pdf(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- conn.close()
-
- settings = get_app_settings()
-
- buffer = BytesIO()
- pdf = canvas.Canvas(buffer, pagesize=letter)
- width, height = letter
-
- left = 50
- right = 560
- y = height - 50
-
- def draw_line(txt, x=left, font="Helvetica", size=11):
- nonlocal y
- pdf.setFont(font, size)
- pdf.drawString(x, y, str(txt) if txt is not None else "")
- y -= 16
-
- def money(value, currency="CAD"):
- return f"{to_decimal(value):.2f} {currency}"
-
- pdf.setTitle(f"Invoice {invoice['invoice_number']}")
-
- logo_url = (settings.get("business_logo_url") or "").strip()
- if logo_url.startswith("/static/"):
- local_logo_path = "/home/def/otb_billing" + logo_url
- try:
- pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto')
- except Exception:
- pass
-
- pdf.setFont("Helvetica-Bold", 22)
- pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}")
-
- pdf.setFont("Helvetica-Bold", 14)
- pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing")
- y -= 18
- pdf.setFont("Helvetica", 12)
- pdf.drawRightString(right, y, settings.get("business_tagline") or "")
- y -= 15
-
- right_lines = [
- settings.get("business_address", ""),
- settings.get("business_email", ""),
- settings.get("business_phone", ""),
- settings.get("business_website", ""),
- ]
- for item in right_lines:
- if item:
- pdf.drawRightString(right, y, item[:80])
- y -= 14
-
- y -= 10
-
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, "Status:")
- pdf.setFont("Helvetica", 12)
- pdf.drawString(left + 45, y, str(invoice["status"]).upper())
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Bill To")
- y -= 20
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, invoice["company_name"] or "")
- y -= 16
- pdf.setFont("Helvetica", 11)
- if invoice.get("contact_name"):
- pdf.drawString(left, y, str(invoice["contact_name"]))
- y -= 15
- if invoice.get("email"):
- pdf.drawString(left, y, str(invoice["email"]))
- y -= 15
- if invoice.get("phone"):
- pdf.drawString(left, y, str(invoice["phone"]))
- y -= 15
- pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Invoice Details")
- y -= 20
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}")
- y -= 15
- pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}")
- y -= 15
- if invoice.get("paid_at"):
- pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Service Code")
- pdf.drawString(180, y, "Service")
- pdf.drawString(330, y, "Description")
- pdf.drawRightString(right, y, "Total")
- y -= 14
- pdf.line(left, y, right, y)
- y -= 18
-
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, str(invoice.get("service_code") or "-"))
- pdf.drawString(180, y, str(invoice.get("service_name") or "-"))
- pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28])
- pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD")))
- y -= 28
-
- totals_x_label = 360
- totals_x_value = right
-
- totals = [
- ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))),
- ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))),
- ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))),
- ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))),
- ]
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
-
- for label, value in totals:
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, label)
- pdf.setFont("Helvetica", 11)
- pdf.drawRightString(totals_x_value, y, value)
- y -= 18
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, "Remaining")
- pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}")
- y -= 25
-
- if settings.get("tax_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}")
- y -= 14
-
- if settings.get("business_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}")
- y -= 14
-
- if settings.get("payment_terms"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Payment Terms")
- y -= 15
- pdf.setFont("Helvetica", 10)
- for chunk_start in range(0, len(settings.get("payment_terms", "")), 90):
- line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- if settings.get("invoice_footer"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Footer")
- y -= 15
- pdf.setFont("Helvetica", 10)
- for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90):
- line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- pdf.showPage()
- pdf.save()
- buffer.seek(0)
-
- return send_file(
- buffer,
- mimetype="application/pdf",
- as_attachment=True,
- download_name=f"{invoice['invoice_number']}.pdf"
- )
-
-
-@app.route("/invoices/view/")
-def view_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- conn.close()
- settings = get_app_settings()
- return render_template("invoices/view.html", invoice=invoice, settings=settings)
-
-
-@app.route("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-
-
-@app.route("/payments/export.csv")
-def export_payments_csv():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT
- p.id,
- p.invoice_id,
- i.invoice_number,
- p.client_id,
- c.client_code,
- c.company_name,
- p.payment_method,
- p.payment_currency,
- p.payment_amount,
- p.cad_value_at_payment,
- p.reference,
- p.sender_name,
- p.txid,
- p.wallet_address,
- p.payment_status,
- p.received_at,
- p.notes
- 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 ASC
- """)
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "invoice_id",
- "invoice_number",
- "client_id",
- "client_code",
- "company_name",
- "payment_method",
- "payment_currency",
- "payment_amount",
- "cad_value_at_payment",
- "reference",
- "sender_name",
- "txid",
- "wallet_address",
- "payment_status",
- "received_at",
- "notes",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("invoice_id", ""),
- r.get("invoice_number", ""),
- r.get("client_id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("payment_method", ""),
- r.get("payment_currency", ""),
- r.get("payment_amount", ""),
- r.get("cad_value_at_payment", ""),
- r.get("reference", ""),
- r.get("sender_name", ""),
- r.get("txid", ""),
- r.get("wallet_address", ""),
- r.get("payment_status", ""),
- r.get("received_at", ""),
- r.get("notes", ""),
- ])
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = "attachment; filename=payments.csv"
- return response
-
-@app.route("/payments")
-def payments():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- p.*,
- i.invoice_number,
- i.status AS invoice_status,
- i.total_amount,
- i.amount_paid,
- i.currency_code AS invoice_currency_code,
- 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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app-backups/app.py.bak_payments_query_fix b/backend/app-backups/app.py.bak_payments_query_fix
deleted file mode 100644
index 187be3f..0000000
--- a/backend/app-backups/app.py.bak_payments_query_fix
+++ /dev/null
@@ -1,1224 +0,0 @@
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-@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()
-
- for client in clients:
- client["credit_balance"] = get_client_credit_balance(client["id"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-@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,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- 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("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-@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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app-backups/app.py.bak_payments_route_exact_fix b/backend/app-backups/app.py.bak_payments_route_exact_fix
deleted file mode 100644
index 187be3f..0000000
--- a/backend/app-backups/app.py.bak_payments_route_exact_fix
+++ /dev/null
@@ -1,1224 +0,0 @@
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-@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()
-
- for client in clients:
- client["credit_balance"] = get_client_credit_balance(client["id"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-@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,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- 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("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-@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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app-backups/app.py.bak_payments_route_fix2 b/backend/app-backups/app.py.bak_payments_route_fix2
deleted file mode 100644
index 187be3f..0000000
--- a/backend/app-backups/app.py.bak_payments_route_fix2
+++ /dev/null
@@ -1,1224 +0,0 @@
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-@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()
-
- for client in clients:
- client["credit_balance"] = get_client_credit_balance(client["id"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-@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,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- 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("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-@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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app-backups/app.py.bak_report_email_fix b/backend/app-backups/app.py.bak_report_email_fix
deleted file mode 100644
index 2591e2c..0000000
--- a/backend/app-backups/app.py.bak_report_email_fix
+++ /dev/null
@@ -1,2581 +0,0 @@
-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 zoneinfo import ZoneInfo
-from decimal import Decimal, InvalidOperation
-from email.message import EmailMessage
-
-from io import BytesIO, StringIO
-import csv
-import zipfile
-import smtplib
-from reportlab.lib.pagesizes import letter
-from reportlab.pdfgen import canvas
-from reportlab.lib.utils import ImageReader
-
-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}
-
-@app.context_processor
-def inject_app_settings():
- return {"app_settings": get_app_settings()}
-
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-
-def generate_invoice_number():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT invoice_number
- FROM invoices
- WHERE invoice_number IS NOT NULL
- AND invoice_number LIKE 'INV-%'
- ORDER BY id DESC
- LIMIT 1
- """)
- row = cursor.fetchone()
- conn.close()
-
- if not row or not row.get("invoice_number"):
- return "INV-0001"
-
- invoice_number = str(row["invoice_number"]).strip()
-
- try:
- number = int(invoice_number.split("-")[1])
- except (IndexError, ValueError):
- return "INV-0001"
-
- return f"INV-{number + 1:04d}"
-
-
-APP_SETTINGS_DEFAULTS = {
- "business_name": "OTB Billing",
- "business_tagline": "By a contractor, for contractors",
- "business_logo_url": "",
- "business_email": "",
- "business_phone": "",
- "business_address": "",
- "business_website": "",
- "tax_label": "HST",
- "tax_rate": "13.00",
- "tax_number": "",
- "business_number": "",
- "default_currency": "CAD",
- "report_frequency": "monthly",
- "invoice_footer": "",
- "payment_terms": "",
- "local_country": "Canada",
- "apply_local_tax_only": "1",
- "smtp_host": "",
- "smtp_port": "587",
- "smtp_user": "",
- "smtp_pass": "",
- "smtp_from_email": "",
- "smtp_from_name": "",
- "smtp_use_tls": "1",
- "smtp_use_ssl": "0",
- "report_delivery_email": "",
-}
-
-def ensure_app_settings_table():
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS app_settings (
- setting_key VARCHAR(100) NOT NULL PRIMARY KEY,
- setting_value TEXT NULL,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- )
- """)
- conn.commit()
- conn.close()
-
-def get_app_settings():
- ensure_app_settings_table()
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT setting_key, setting_value
- FROM app_settings
- """)
- rows = cursor.fetchall()
- conn.close()
-
- settings = dict(APP_SETTINGS_DEFAULTS)
- for row in rows:
- settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else ""
-
- return settings
-
-def save_app_settings(form_data):
- ensure_app_settings_table()
- conn = get_db_connection()
- cursor = conn.cursor()
-
- for key in APP_SETTINGS_DEFAULTS.keys():
- if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}:
- value = "1" if form_data.get(key) else "0"
- else:
- value = (form_data.get(key) or "").strip()
-
- cursor.execute("""
- INSERT INTO app_settings (setting_key, setting_value)
- VALUES (%s, %s)
- ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)
- """, (key, value))
-
- 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)
-
-
-
-
-def get_report_period_bounds(frequency):
- now_local = datetime.now(LOCAL_TZ)
-
- if frequency == "yearly":
- start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
- label = f"{now_local.year}"
- elif frequency == "quarterly":
- quarter = ((now_local.month - 1) // 3) + 1
- start_month = (quarter - 1) * 3 + 1
- start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0)
- label = f"Q{quarter} {now_local.year}"
- else:
- start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
- label = now_local.strftime("%B %Y")
-
- start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None)
- end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None)
-
- return start_utc, end_utc, label
-
-def get_revenue_report_data():
- settings = get_app_settings()
- frequency = (settings.get("report_frequency") or "monthly").strip().lower()
- if frequency not in {"monthly", "quarterly", "yearly"}:
- frequency = "monthly"
-
- start_utc, end_utc, label = get_report_period_bounds(frequency)
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected
- FROM payments
- WHERE payment_status = 'confirmed'
- AND received_at >= %s
- AND received_at <= %s
- """, (start_utc, end_utc))
- collected_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS invoice_count,
- COALESCE(SUM(total_amount), 0) AS invoiced
- FROM invoices
- WHERE issued_at >= %s
- AND issued_at <= %s
- """, (start_utc, end_utc))
- invoiced_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS overdue_count,
- COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance
- FROM invoices
- WHERE status = 'overdue'
- """)
- overdue_row = cursor.fetchone()
-
- cursor.execute("""
- SELECT COUNT(*) AS outstanding_count,
- COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance
- FROM invoices
- WHERE status IN ('pending', 'partial', 'overdue')
- """)
- outstanding_row = cursor.fetchone()
-
- conn.close()
-
- return {
- "frequency": frequency,
- "period_label": label,
- "period_start": start_utc.isoformat(sep=" "),
- "period_end": end_utc.isoformat(sep=" "),
- "collected_cad": str(to_decimal(collected_row["collected"])),
- "invoice_count": int(invoiced_row["invoice_count"] or 0),
- "invoiced_total": str(to_decimal(invoiced_row["invoiced"])),
- "overdue_count": int(overdue_row["overdue_count"] or 0),
- "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])),
- "outstanding_count": int(outstanding_row["outstanding_count"] or 0),
- "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])),
- }
-
-
-def ensure_email_log_table():
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS email_log (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- email_type VARCHAR(50) NOT NULL,
- invoice_id INT UNSIGNED NULL,
- recipient_email VARCHAR(255) NOT NULL,
- subject VARCHAR(255) NOT NULL,
- status VARCHAR(20) NOT NULL,
- error_message TEXT NULL,
- sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- KEY idx_email_log_invoice_id (invoice_id),
- KEY idx_email_log_type (email_type),
- KEY idx_email_log_sent_at (sent_at)
- )
- """)
- conn.commit()
- conn.close()
-
-
-def log_email_event(email_type, recipient_email, subject, status, invoice_id=None, error_message=None):
- ensure_email_log_table()
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- INSERT INTO email_log
- (email_type, invoice_id, recipient_email, subject, status, error_message)
- VALUES (%s, %s, %s, %s, %s, %s)
- """, (
- email_type,
- invoice_id,
- recipient_email,
- subject,
- status,
- error_message
- ))
- conn.commit()
- conn.close()
-
-
-
-def send_configured_email(to_email, subject, body, attachments=None, email_type="system_email", invoice_id=None):
- settings = get_app_settings()
-
- smtp_host = (settings.get("smtp_host") or "").strip()
- smtp_port = int((settings.get("smtp_port") or "587").strip() or "587")
- smtp_user = (settings.get("smtp_user") or "").strip()
- smtp_pass = (settings.get("smtp_pass") or "").strip()
- from_email = (settings.get("smtp_from_email") or settings.get("business_email") or "").strip()
- from_name = (settings.get("smtp_from_name") or settings.get("business_name") or "").strip()
- use_tls = (settings.get("smtp_use_tls") or "0") == "1"
- use_ssl = (settings.get("smtp_use_ssl") or "0") == "1"
-
- if not smtp_host:
- raise ValueError("SMTP host is not configured.")
- if not from_email:
- raise ValueError("From email is not configured.")
- if not to_email:
- raise ValueError("Recipient email is missing.")
-
- msg = EmailMessage()
- msg["Subject"] = subject
- msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
- msg["To"] = to_email
- msg.set_content(body)
-
- for attachment in attachments or []:
- filename = attachment["filename"]
- mime_type = attachment["mime_type"]
- data = attachment["data"]
- maintype, subtype = mime_type.split("/", 1)
- msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
-
- try:
- if use_ssl:
- with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30) as server:
- if smtp_user:
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
- else:
- with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
- server.ehlo()
- if use_tls:
- server.starttls()
- server.ehlo()
- if smtp_user:
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
-
- log_email_event(email_type, to_email, subject, "sent", invoice_id=invoice_id, error_message=None)
- except Exception as e:
- log_email_event(email_type, to_email, subject, "failed", invoice_id=invoice_id, error_message=str(e))
- raise
-
-@app.route("/settings", methods=["GET", "POST"])
-def settings():
- ensure_app_settings_table()
-
- if request.method == "POST":
- save_app_settings(request.form)
- return redirect("/settings")
-
- settings = get_app_settings()
- return render_template("settings.html", settings=settings)
-
-
-@app.route("/reports/revenue")
-def revenue_report():
- report = get_revenue_report_data()
- return render_template("reports/revenue.html", report=report)
-
-@app.route("/reports/revenue.json")
-def revenue_report_json():
- report = get_revenue_report_data()
- return jsonify(report)
-
-@app.route("/reports/revenue/print")
-def revenue_report_print():
- report = get_revenue_report_data()
- return render_template("reports/revenue_print.html", report=report)
-
-
-
-@app.route("/invoices/email/", methods=["POST"])
-def email_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
- conn.close()
-
- if not invoice:
- return "Invoice not found", 404
-
- recipient = (invoice.get("email") or "").strip()
- if not recipient:
- return "Client email is missing for this invoice.", 400
-
- settings = get_app_settings()
-
- with app.test_client() as client:
- pdf_response = client.get(f"/invoices/pdf/{invoice_id}")
- if pdf_response.status_code != 200:
- return "Could not generate invoice PDF for email.", 500
-
- pdf_bytes = pdf_response.data
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
- subject = f"Invoice {invoice['invoice_number']} from {settings.get('business_name') or 'OTB Billing'}"
- body = (
- f"Hello {invoice.get('contact_name') or invoice.get('company_name') or ''},\n\n"
- f"Please find attached invoice {invoice['invoice_number']}.\n"
- f"Total: {to_decimal(invoice.get('total_amount')):.2f} {invoice.get('currency_code', 'CAD')}\n"
- f"Remaining: {remaining:.2f} {invoice.get('currency_code', 'CAD')}\n"
- f"Due: {fmt_local(invoice.get('due_at'))}\n\n"
- f"Thank you,\n"
- f"{settings.get('business_name') or 'OTB Billing'}"
- )
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- email_type="invoice",
- invoice_id=invoice_id,
- attachments=[{
- "filename": f"{invoice['invoice_number']}.pdf",
- "mime_type": "application/pdf",
- "data": pdf_bytes,
- }]
- )
- return redirect(f"/invoices/view/{invoice_id}?email_sent=1")
- except Exception:
- return redirect(f"/invoices/view/{invoice_id}?email_failed=1")
-
-
-@app.route("/reports/revenue/email", methods=["POST"])
-def email_revenue_report_json():
- settings = get_app_settings()
- recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip()
- if not recipient:
- return "Report delivery email is not configured.", 400
-
- with app.test_client() as client:
- json_response = client.get("/reports/revenue.json")
- if json_response.status_code != 200:
- return "Could not generate revenue report JSON.", 500
-
- report = get_revenue_report_data()
- subject = f"Revenue Report {report.get('period_label', '')} from {settings.get('business_name') or 'OTB Billing'}"
- body = (
- f"Attached is the revenue report JSON for {report.get('period_label', '')}.\n\n"
- f"Frequency: {report.get('frequency', '')}\n"
- f"Collected CAD: {report.get('collected_cad', '')}\n"
- f"Invoices Issued: {report.get('invoice_count', '')}\n"
- )
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- email_type="invoice",
- invoice_id=invoice_id,
- attachments=[{
- "filename": "revenue_report.json",
- "mime_type": "application/json",
- "data": json_response.data,
- }]
- )
- return redirect("/reports/revenue?email_sent=1")
- except Exception:
- return redirect("/reports/revenue?email_failed=1")
-
-
-@app.route("/reports/accounting-package/email", methods=["POST"])
-def email_accounting_package():
- settings = get_app_settings()
- recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip()
- if not recipient:
- return "Report delivery email is not configured.", 400
-
- with app.test_client() as client:
- zip_response = client.get("/reports/accounting-package.zip")
- if zip_response.status_code != 200:
- return "Could not generate accounting package ZIP.", 500
-
- subject = f"Accounting Package from {settings.get('business_name') or 'OTB Billing'}"
- body = "Attached is the latest accounting package export."
-
- try:
- send_configured_email(
- recipient,
- subject,
- body,
- email_type="invoice",
- invoice_id=invoice_id,
- attachments=[{
- "filename": "accounting_package.zip",
- "mime_type": "application/zip",
- "data": zip_response.data,
- }]
- )
- return redirect("/?pkg_email=1")
- except Exception:
- return redirect("/?pkg_email_failed=1")
-
-@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/export.csv")
-def export_clients_csv():
- 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
- """)
- 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"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-
-
-
-
-
-@app.route("/invoices/export.csv")
-def export_invoices_csv():
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.id,
- i.invoice_number,
- i.client_id,
- c.client_code,
- c.company_name,
- i.service_id,
- i.currency_code,
- i.subtotal_amount,
- i.tax_amount,
- i.total_amount,
- i.amount_paid,
- i.status,
- i.issued_at,
- i.due_at,
- i.paid_at,
- i.notes,
- i.created_at,
- i.updated_at
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "invoice_number",
- "client_id",
- "client_code",
- "company_name",
- "service_id",
- "currency_code",
- "subtotal_amount",
- "tax_amount",
- "total_amount",
- "amount_paid",
- "status",
- "issued_at",
- "due_at",
- "paid_at",
- "notes",
- "created_at",
- "updated_at",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("invoice_number", ""),
- r.get("client_id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("service_id", ""),
- r.get("currency_code", ""),
- r.get("subtotal_amount", ""),
- r.get("tax_amount", ""),
- r.get("total_amount", ""),
- r.get("amount_paid", ""),
- r.get("status", ""),
- r.get("issued_at", ""),
- r.get("due_at", ""),
- r.get("paid_at", ""),
- r.get("notes", ""),
- r.get("created_at", ""),
- r.get("updated_at", ""),
- ])
-
- filename = "invoices"
- if start_date or end_date or status or client_id or limit_count:
- filename += "_filtered"
- filename += ".csv"
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = f"attachment; filename={filename}"
- return response
-
-
-@app.route("/invoices/export-pdf.zip")
-def export_invoices_pdf_zip():
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
- conn.close()
-
- settings = get_app_settings()
-
- def build_invoice_pdf_bytes(invoice, settings):
- buffer = BytesIO()
- pdf = canvas.Canvas(buffer, pagesize=letter)
- width, height = letter
-
- left = 50
- right = 560
- y = height - 50
-
- def money(value, currency="CAD"):
- return f"{to_decimal(value):.2f} {currency}"
-
- pdf.setTitle(f"Invoice {invoice['invoice_number']}")
-
- logo_url = (settings.get("business_logo_url") or "").strip()
- if logo_url.startswith("/static/"):
- local_logo_path = "/home/def/otb_billing" + logo_url
- try:
- pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto')
- except Exception:
- pass
-
- pdf.setFont("Helvetica-Bold", 22)
- pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}")
-
- pdf.setFont("Helvetica-Bold", 14)
- pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing")
- y -= 18
- pdf.setFont("Helvetica", 12)
- pdf.drawRightString(right, y, settings.get("business_tagline") or "")
- y -= 15
-
- right_lines = [
- settings.get("business_address", ""),
- settings.get("business_email", ""),
- settings.get("business_phone", ""),
- settings.get("business_website", ""),
- ]
- for item in right_lines:
- if item:
- pdf.drawRightString(right, y, item[:80])
- y -= 14
-
- y -= 10
-
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, "Status:")
- pdf.setFont("Helvetica", 12)
- pdf.drawString(left + 45, y, str(invoice["status"]).upper())
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Bill To")
- y -= 20
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, invoice["company_name"] or "")
- y -= 16
- pdf.setFont("Helvetica", 11)
- if invoice.get("contact_name"):
- pdf.drawString(left, y, str(invoice["contact_name"]))
- y -= 15
- if invoice.get("email"):
- pdf.drawString(left, y, str(invoice["email"]))
- y -= 15
- if invoice.get("phone"):
- pdf.drawString(left, y, str(invoice["phone"]))
- y -= 15
- pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Invoice Details")
- y -= 20
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}")
- y -= 15
- pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}")
- y -= 15
- if invoice.get("paid_at"):
- pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Service Code")
- pdf.drawString(180, y, "Service")
- pdf.drawString(330, y, "Description")
- pdf.drawRightString(right, y, "Total")
- y -= 14
- pdf.line(left, y, right, y)
- y -= 18
-
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, str(invoice.get("service_code") or "-"))
- pdf.drawString(180, y, str(invoice.get("service_name") or "-"))
- pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28])
- pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD")))
- y -= 28
-
- totals_x_label = 360
- totals_x_value = right
-
- totals = [
- ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))),
- ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))),
- ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))),
- ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))),
- ]
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
-
- for label, value in totals:
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, label)
- pdf.setFont("Helvetica", 11)
- pdf.drawRightString(totals_x_value, y, value)
- y -= 18
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, "Remaining")
- pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}")
- y -= 25
-
- if settings.get("tax_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}")
- y -= 14
-
- if settings.get("business_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}")
- y -= 14
-
- if settings.get("payment_terms"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Payment Terms")
- y -= 15
- pdf.setFont("Helvetica", 10)
- terms = settings.get("payment_terms", "")
- for chunk_start in range(0, len(terms), 90):
- line_text = terms[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- if settings.get("invoice_footer"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Footer")
- y -= 15
- pdf.setFont("Helvetica", 10)
- footer = settings.get("invoice_footer", "")
- for chunk_start in range(0, len(footer), 90):
- line_text = footer[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- pdf.showPage()
- pdf.save()
- buffer.seek(0)
- return buffer.getvalue()
-
- zip_buffer = BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
- for invoice in invoices:
- pdf_bytes = build_invoice_pdf_bytes(invoice, settings)
- zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes)
-
- zip_buffer.seek(0)
-
- filename = "invoices_export"
- if start_date:
- filename += f"_{start_date}"
- if end_date:
- filename += f"_to_{end_date}"
- if status:
- filename += f"_{status}"
- if client_id:
- filename += f"_client_{client_id}"
- if limit_count:
- filename += f"_limit_{limit_count}"
- filename += ".zip"
-
- return send_file(
- zip_buffer,
- mimetype="application/zip",
- as_attachment=True,
- download_name=filename
- )
-
-
-@app.route("/invoices/print")
-def print_invoices():
- refresh_overdue_invoices()
-
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id ASC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
- conn.close()
-
- settings = get_app_settings()
-
- filters = {
- "start_date": start_date,
- "end_date": end_date,
- "status": status,
- "client_id": client_id,
- "limit": limit_count,
- }
-
- return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters)
-
-@app.route("/invoices")
-def invoices():
- refresh_overdue_invoices()
-
- start_date = (request.args.get("start_date") or "").strip()
- end_date = (request.args.get("end_date") or "").strip()
- status = (request.args.get("status") or "").strip()
- client_id = (request.args.get("client_id") or "").strip()
- limit_count = (request.args.get("limit") or "").strip()
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- query = """
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE 1=1
- """
- params = []
-
- if start_date:
- query += " AND DATE(i.issued_at) >= %s"
- params.append(start_date)
-
- if end_date:
- query += " AND DATE(i.issued_at) <= %s"
- params.append(end_date)
-
- if status:
- query += " AND i.status = %s"
- params.append(status)
-
- if client_id:
- query += " AND i.client_id = %s"
- params.append(client_id)
-
- query += " ORDER BY i.id DESC"
-
- if limit_count:
- try:
- limit_int = int(limit_count)
- if limit_int > 0:
- query += " LIMIT %s"
- params.append(limit_int)
- except ValueError:
- pass
-
- cursor.execute(query, tuple(params))
- invoices = cursor.fetchall()
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- ORDER BY company_name ASC
- """)
- clients = cursor.fetchall()
-
- conn.close()
-
- filters = {
- "start_date": start_date,
- "end_date": end_date,
- "status": status,
- "client_id": client_id,
- "limit": limit_count,
- }
-
- return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients)
-
-@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,
- )
-
- invoice_number = generate_invoice_number()
-
- 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("/invoices/pdf/")
-def invoice_pdf(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- conn.close()
-
- settings = get_app_settings()
-
- buffer = BytesIO()
- pdf = canvas.Canvas(buffer, pagesize=letter)
- width, height = letter
-
- left = 50
- right = 560
- y = height - 50
-
- def draw_line(txt, x=left, font="Helvetica", size=11):
- nonlocal y
- pdf.setFont(font, size)
- pdf.drawString(x, y, str(txt) if txt is not None else "")
- y -= 16
-
- def money(value, currency="CAD"):
- return f"{to_decimal(value):.2f} {currency}"
-
- pdf.setTitle(f"Invoice {invoice['invoice_number']}")
-
- logo_url = (settings.get("business_logo_url") or "").strip()
- if logo_url.startswith("/static/"):
- local_logo_path = "/home/def/otb_billing" + logo_url
- try:
- pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto')
- except Exception:
- pass
-
- pdf.setFont("Helvetica-Bold", 22)
- pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}")
-
- pdf.setFont("Helvetica-Bold", 14)
- pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing")
- y -= 18
- pdf.setFont("Helvetica", 12)
- pdf.drawRightString(right, y, settings.get("business_tagline") or "")
- y -= 15
-
- right_lines = [
- settings.get("business_address", ""),
- settings.get("business_email", ""),
- settings.get("business_phone", ""),
- settings.get("business_website", ""),
- ]
- for item in right_lines:
- if item:
- pdf.drawRightString(right, y, item[:80])
- y -= 14
-
- y -= 10
-
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, "Status:")
- pdf.setFont("Helvetica", 12)
- pdf.drawString(left + 45, y, str(invoice["status"]).upper())
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Bill To")
- y -= 20
- pdf.setFont("Helvetica-Bold", 12)
- pdf.drawString(left, y, invoice["company_name"] or "")
- y -= 16
- pdf.setFont("Helvetica", 11)
- if invoice.get("contact_name"):
- pdf.drawString(left, y, str(invoice["contact_name"]))
- y -= 15
- if invoice.get("email"):
- pdf.drawString(left, y, str(invoice["email"]))
- y -= 15
- if invoice.get("phone"):
- pdf.drawString(left, y, str(invoice["phone"]))
- y -= 15
- pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 13)
- pdf.drawString(left, y, "Invoice Details")
- y -= 20
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}")
- y -= 15
- pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}")
- y -= 15
- if invoice.get("paid_at"):
- pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}")
- y -= 15
- pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}")
- y -= 28
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Service Code")
- pdf.drawString(180, y, "Service")
- pdf.drawString(330, y, "Description")
- pdf.drawRightString(right, y, "Total")
- y -= 14
- pdf.line(left, y, right, y)
- y -= 18
-
- pdf.setFont("Helvetica", 11)
- pdf.drawString(left, y, str(invoice.get("service_code") or "-"))
- pdf.drawString(180, y, str(invoice.get("service_name") or "-"))
- pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28])
- pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD")))
- y -= 28
-
- totals_x_label = 360
- totals_x_value = right
-
- totals = [
- ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))),
- ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))),
- ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))),
- ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))),
- ]
-
- remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
-
- for label, value in totals:
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, label)
- pdf.setFont("Helvetica", 11)
- pdf.drawRightString(totals_x_value, y, value)
- y -= 18
-
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(totals_x_label, y, "Remaining")
- pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}")
- y -= 25
-
- if settings.get("tax_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}")
- y -= 14
-
- if settings.get("business_number"):
- pdf.setFont("Helvetica", 10)
- pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}")
- y -= 14
-
- if settings.get("payment_terms"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Payment Terms")
- y -= 15
- pdf.setFont("Helvetica", 10)
- for chunk_start in range(0, len(settings.get("payment_terms", "")), 90):
- line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- if settings.get("invoice_footer"):
- y -= 8
- pdf.setFont("Helvetica-Bold", 11)
- pdf.drawString(left, y, "Footer")
- y -= 15
- pdf.setFont("Helvetica", 10)
- for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90):
- line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90]
- pdf.drawString(left, y, line_text)
- y -= 13
-
- pdf.showPage()
- pdf.save()
- buffer.seek(0)
-
- return send_file(
- buffer,
- mimetype="application/pdf",
- as_attachment=True,
- download_name=f"{invoice['invoice_number']}.pdf"
- )
-
-
-@app.route("/invoices/view/")
-def view_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- i.*,
- c.client_code,
- c.company_name,
- c.contact_name,
- c.email,
- c.phone,
- s.service_code,
- s.service_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- LEFT JOIN services s ON i.service_id = s.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- conn.close()
- settings = get_app_settings()
- return render_template("invoices/view.html", invoice=invoice, settings=settings)
-
-
-@app.route("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-
-
-@app.route("/payments/export.csv")
-def export_payments_csv():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT
- p.id,
- p.invoice_id,
- i.invoice_number,
- p.client_id,
- c.client_code,
- c.company_name,
- p.payment_method,
- p.payment_currency,
- p.payment_amount,
- p.cad_value_at_payment,
- p.reference,
- p.sender_name,
- p.txid,
- p.wallet_address,
- p.payment_status,
- p.received_at,
- p.notes
- 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 ASC
- """)
- rows = cursor.fetchall()
- conn.close()
-
- output = StringIO()
- writer = csv.writer(output)
- writer.writerow([
- "id",
- "invoice_id",
- "invoice_number",
- "client_id",
- "client_code",
- "company_name",
- "payment_method",
- "payment_currency",
- "payment_amount",
- "cad_value_at_payment",
- "reference",
- "sender_name",
- "txid",
- "wallet_address",
- "payment_status",
- "received_at",
- "notes",
- ])
-
- for r in rows:
- writer.writerow([
- r.get("id", ""),
- r.get("invoice_id", ""),
- r.get("invoice_number", ""),
- r.get("client_id", ""),
- r.get("client_code", ""),
- r.get("company_name", ""),
- r.get("payment_method", ""),
- r.get("payment_currency", ""),
- r.get("payment_amount", ""),
- r.get("cad_value_at_payment", ""),
- r.get("reference", ""),
- r.get("sender_name", ""),
- r.get("txid", ""),
- r.get("wallet_address", ""),
- r.get("payment_status", ""),
- r.get("received_at", ""),
- r.get("notes", ""),
- ])
-
- response = make_response(output.getvalue())
- response.headers["Content-Type"] = "text/csv; charset=utf-8"
- response.headers["Content-Disposition"] = "attachment; filename=payments.csv"
- return response
-
-@app.route("/payments")
-def payments():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT
- p.*,
- i.invoice_number,
- i.status AS invoice_status,
- i.total_amount,
- i.amount_paid,
- i.currency_code AS invoice_currency_code,
- 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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app-backups/app.py.bak_void_fix b/backend/app-backups/app.py.bak_void_fix
deleted file mode 100644
index 26ecc9c..0000000
--- a/backend/app-backups/app.py.bak_void_fix
+++ /dev/null
@@ -1,1220 +0,0 @@
-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()
-
-def recalc_invoice_totals(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, total_amount, due_at, status
- FROM invoices
- WHERE id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return
-
- cursor.execute("""
- SELECT COALESCE(SUM(payment_amount), 0) AS total_paid
- FROM payments
- WHERE invoice_id = %s
- AND payment_status = 'confirmed'
- """, (invoice_id,))
- row = cursor.fetchone()
-
- total_paid = to_decimal(row["total_paid"])
- total_amount = to_decimal(invoice["total_amount"])
-
- if invoice["status"] == "cancelled":
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET amount_paid = %s,
- paid_at = NULL
- WHERE id = %s
- """, (
- str(total_paid),
- invoice_id
- ))
- conn.commit()
- conn.close()
- return
-
- if total_paid >= total_amount and total_amount > 0:
- new_status = "paid"
- paid_at_value = "UTC_TIMESTAMP()"
- elif total_paid > 0:
- new_status = "partial"
- paid_at_value = "NULL"
- else:
- if invoice["due_at"] and invoice["due_at"] < datetime.utcnow():
- new_status = "overdue"
- else:
- new_status = "pending"
- paid_at_value = "NULL"
-
- update_cursor = conn.cursor()
- update_cursor.execute(f"""
- UPDATE invoices
- SET amount_paid = %s,
- status = %s,
- paid_at = {paid_at_value}
- WHERE id = %s
- """, (
- str(total_paid),
- new_status,
- invoice_id
- ))
-
- conn.commit()
- conn.close()
-
-def get_client_credit_balance(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT COALESCE(SUM(amount), 0) AS balance
- FROM credit_ledger
- WHERE client_id = %s
- """, (client_id,))
- row = cursor.fetchone()
- conn.close()
- return to_decimal(row["balance"])
-
-@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()
-
- for client in clients:
- client["credit_balance"] = get_client_credit_balance(client["id"])
-
- 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()
- client["credit_balance"] = get_client_credit_balance(client_id)
- 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
-
- client["credit_balance"] = get_client_credit_balance(client_id)
-
- return render_template("clients/edit.html", client=client, errors=[])
-
-@app.route("/credits/")
-def client_credits(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- cursor.execute("""
- SELECT *
- FROM credit_ledger
- WHERE client_id = %s
- ORDER BY id DESC
- """, (client_id,))
- entries = cursor.fetchall()
-
- conn.close()
-
- balance = get_client_credit_balance(client_id)
-
- return render_template(
- "credits/list.html",
- client=client,
- entries=entries,
- balance=balance,
- )
-
-@app.route("/credits/add/", methods=["GET", "POST"])
-def add_credit(client_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, client_code, company_name
- FROM clients
- WHERE id = %s
- """, (client_id,))
- client = cursor.fetchone()
-
- if not client:
- conn.close()
- return "Client not found", 404
-
- if request.method == "POST":
- entry_type = request.form.get("entry_type", "").strip()
- amount = request.form.get("amount", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- notes = request.form.get("notes", "").strip()
-
- errors = []
-
- if not entry_type:
- errors.append("Entry type is required.")
- if not amount:
- errors.append("Amount is required.")
- if not currency_code:
- errors.append("Currency code is required.")
-
- if not errors:
- try:
- amount_value = Decimal(str(amount))
- if amount_value == 0:
- errors.append("Amount cannot be zero.")
- except Exception:
- errors.append("Amount must be a valid number.")
-
- if errors:
- conn.close()
- return render_template("credits/add.html", client=client, errors=errors)
-
- insert_cursor = conn.cursor()
- insert_cursor.execute("""
- INSERT INTO credit_ledger
- (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes
- )
- VALUES (%s, %s, %s, %s, %s)
- """, (
- client_id,
- entry_type,
- amount,
- currency_code,
- notes or None
- ))
- conn.commit()
- conn.close()
-
- return redirect(f"/credits/{client_id}")
-
- conn.close()
- return render_template("credits/add.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("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- """, (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute("""
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- """, (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-
-@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,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- 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("/invoices/edit/", methods=["GET", "POST"])
-def edit_invoice(invoice_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT i.*,
- COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count
- FROM invoices i
- WHERE i.id = %s
- """, (invoice_id,))
- invoice = cursor.fetchone()
-
- if not invoice:
- conn.close()
- return "Invoice not found", 404
-
- locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
-
- if request.method == "POST":
- due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
-
- if locked:
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET due_at = %s,
- notes = %s
- WHERE id = %s
- """, (
- due_at or None,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- 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()
- status = request.form.get("status", "").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 status:
- errors.append("Status is required.")
-
- manual_statuses = {"draft", "pending", "cancelled"}
- if status and status not in manual_statuses:
- errors.append("Manual invoice status must be draft, pending, or cancelled.")
-
- if not errors:
- try:
- amount_value = float(total_amount)
- if amount_value < 0:
- errors.append("Total amount cannot be negative.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
-
- 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()
-
- if errors:
- invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
- invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
- invoice["currency_code"] = currency_code or invoice["currency_code"]
- invoice["total_amount"] = total_amount or invoice["total_amount"]
- invoice["due_at"] = due_at or invoice["due_at"]
- invoice["status"] = status or invoice["status"]
- invoice["notes"] = notes
- conn.close()
- return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE invoices
- SET client_id = %s,
- service_id = %s,
- currency_code = %s,
- total_amount = %s,
- subtotal_amount = %s,
- due_at = %s,
- status = %s,
- notes = %s
- WHERE id = %s
- """, (
- client_id,
- service_id,
- currency_code,
- total_amount,
- total_amount,
- due_at,
- status,
- notes or None,
- invoice_id
- ))
- conn.commit()
- conn.close()
- return redirect("/invoices")
-
- clients = []
- services = []
-
- if not locked:
- 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/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
-
-@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.")
-
- if not errors:
- try:
- payment_amount_value = Decimal(str(payment_amount))
- if payment_amount_value <= Decimal("0"):
- errors.append("Payment amount must be greater than zero.")
- except Exception:
- errors.append("Payment amount must be a valid number.")
-
- if not errors:
- try:
- cad_value_value = Decimal(str(cad_value_at_payment))
- if cad_value_value < Decimal("0"):
- errors.append("CAD value at payment cannot be negative.")
- except Exception:
- errors.append("CAD value at payment must be a valid number.")
-
- invoice_row = None
-
- if not errors:
- cursor.execute("""
- SELECT
- i.id,
- i.client_id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- FROM invoices i
- JOIN clients c ON i.client_id = c.id
- WHERE i.id = %s
- """, (invoice_id,))
- invoice_row = cursor.fetchone()
-
- if not invoice_row:
- errors.append("Selected invoice was not found.")
- else:
- allowed_statuses = {"pending", "partial", "overdue"}
- if invoice_row["status"] not in allowed_statuses:
- errors.append("Payments can only be recorded against pending, partial, or overdue invoices.")
- else:
- remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"])
- entered_amount = to_decimal(payment_amount)
-
- if remaining_balance <= Decimal("0"):
- errors.append("This invoice has no remaining balance.")
- elif entered_amount > remaining_balance:
- errors.append(
- f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}."
- )
-
- if errors:
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 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,
- )
-
- client_id = invoice_row["client_id"]
-
- 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
- ))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.currency_code,
- i.total_amount,
- i.amount_paid,
- i.status,
- c.client_code,
- c.company_name
- 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 i.id DESC
- """)
- invoices = cursor.fetchall()
- conn.close()
-
- return render_template(
- "payments/new.html",
- invoices=invoices,
- errors=[],
- form_data={},
- )
-
-
-
-@app.route("/payments/void/", methods=["POST"])
-def void_payment(payment_id):
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- cursor.execute("""
- SELECT id, invoice_id, payment_status
- FROM payments
- WHERE id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if payment["payment_status"] != "confirmed":
- conn.close()
- return redirect("/payments")
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_status = 'reversed'
- WHERE id = %s
- """, (payment_id,))
-
- conn.commit()
- conn.close()
-
- recalc_invoice_totals(payment["invoice_id"])
-
- return redirect("/payments")
-
-@app.route("/payments/edit/", methods=["GET", "POST"])
-def edit_payment(payment_id):
- 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
- WHERE p.id = %s
- """, (payment_id,))
- payment = cursor.fetchone()
-
- if not payment:
- conn.close()
- return "Payment not found", 404
-
- if request.method == "POST":
- 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 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.")
-
- 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:
- payment["payment_method"] = payment_method or payment["payment_method"]
- payment["payment_currency"] = payment_currency or payment["payment_currency"]
- payment["payment_amount"] = payment_amount or payment["payment_amount"]
- payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"]
- payment["reference"] = reference
- payment["sender_name"] = sender_name
- payment["txid"] = txid
- payment["wallet_address"] = wallet_address
- payment["notes"] = notes
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute("""
- UPDATE payments
- SET payment_method = %s,
- payment_currency = %s,
- payment_amount = %s,
- cad_value_at_payment = %s,
- reference = %s,
- sender_name = %s,
- txid = %s,
- wallet_address = %s,
- notes = %s
- WHERE id = %s
- """, (
- 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,
- payment_id
- ))
- conn.commit()
- invoice_id = payment["invoice_id"]
- conn.close()
-
- recalc_invoice_totals(invoice_id)
-
- return redirect("/payments")
-
- conn.close()
- return render_template("payments/edit.html", payment=payment, errors=[])
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5050, debug=True)
diff --git a/backend/app.py b/backend/app.py
index afc2fbf..88f6af3 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1213,11 +1213,9 @@ def append_square_webhook_log(entry):
pass
def generate_portal_access_code():
- alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
- groups = []
- for _ in range(3):
- groups.append("".join(secrets.choice(alphabet) for _ in range(4)))
- return "-".join(groups)
+ import uuid
+ raw = uuid.uuid4().hex.upper()
+ return f"{raw[0:6]}-{raw[6:12]}-{raw[12:18]}"
def refresh_overdue_invoices():
conn = get_db_connection()
@@ -2702,17 +2700,63 @@ def services():
gate = admin_required()
if gate:
return gate
+
+ selected_type = (request.args.get("service_type") or "").strip()
+ selected_status = (request.args.get("status") or "").strip()
+
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT s.*, c.client_code, c.company_name
+
+ query = """
+ SELECT s.*, c.client_code, c.company_name, st.template_name
FROM services s
JOIN clients c ON s.client_id = c.id
- ORDER BY s.id DESC
- """)
+ LEFT JOIN service_templates st ON s.template_id = st.id
+ WHERE 1=1
+ """
+ params = []
+
+ if selected_type:
+ query += " AND s.service_type = %s"
+ params.append(selected_type)
+
+ if selected_status:
+ query += " AND s.status = %s"
+ params.append(selected_status)
+
+ query += " ORDER BY s.id DESC"
+
+ cursor.execute(query, tuple(params))
services = cursor.fetchall()
+
+ cursor.execute("""
+ SELECT
+ service_type,
+ COUNT(*) AS service_count,
+ COALESCE(SUM(recurring_amount), 0) AS total_monthly
+ FROM services
+ WHERE status = 'active'
+ GROUP BY service_type
+ ORDER BY service_type
+ """)
+ summary_rows = cursor.fetchall()
+
+ active_totals = {
+ "service_count": sum(int(row["service_count"]) for row in summary_rows),
+ "total_monthly": sum(float(row["total_monthly"]) for row in summary_rows),
+ }
+
conn.close()
- return render_template("services/list.html", services=services)
+
+ return render_template(
+ "services/list.html",
+ services=services,
+ selected_type=selected_type,
+ selected_status=selected_status,
+ total_count=len(services),
+ summary_rows=summary_rows,
+ active_totals=active_totals
+ )
@app.route("/services/new", methods=["GET", "POST"])
def new_service():
@@ -2724,6 +2768,7 @@ def new_service():
if request.method == "POST":
client_id = request.form["client_id"]
+ template_id = (request.form.get("template_id") or "").strip() or None
service_name = request.form["service_name"]
service_type = request.form["service_type"]
billing_cycle = request.form["billing_cycle"]
@@ -2744,6 +2789,7 @@ def new_service():
INSERT INTO services
(
client_id,
+ template_id,
service_code,
service_name,
service_type,
@@ -2754,10 +2800,11 @@ def new_service():
start_date,
description
)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
client_id,
+ template_id,
service_code,
service_name,
service_type,
@@ -2774,6 +2821,8 @@ def new_service():
return redirect("/services")
+ preselect_template_id = (request.args.get("template_id") or "").strip()
+
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
clients = cursor.fetchall()
cursor.execute("""
@@ -2784,7 +2833,12 @@ def new_service():
""")
templates = cursor.fetchall()
conn.close()
- return render_template("services/new.html", clients=clients, templates=templates)
+ return render_template(
+ "services/new.html",
+ clients=clients,
+ templates=templates,
+ preselect_template_id=preselect_template_id
+ )
@app.route("/services/edit/", methods=["GET", "POST"])
def edit_service(service_id):
@@ -2796,6 +2850,7 @@ def edit_service(service_id):
if request.method == "POST":
client_id = request.form.get("client_id", "").strip()
+ template_id = (request.form.get("template_id") or "").strip() or None
service_name = request.form.get("service_name", "").strip()
service_type = request.form.get("service_type", "").strip()
billing_cycle = request.form.get("billing_cycle", "").strip()
@@ -2856,6 +2911,7 @@ def edit_service(service_id):
update_cursor.execute("""
UPDATE services
SET client_id = %s,
+ template_id = %s,
service_name = %s,
service_type = %s,
billing_cycle = %s,
@@ -2867,6 +2923,7 @@ def edit_service(service_id):
WHERE id = %s
""", (
client_id,
+ template_id,
service_name,
service_type,
billing_cycle,
@@ -2912,17 +2969,47 @@ def service_templates():
if gate:
return gate
+ selected_type = (request.args.get("service_type") or "").strip()
+ selected_active = (request.args.get("is_active") or "").strip()
+
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
- cursor.execute("""
- SELECT *
- FROM service_templates
- ORDER BY id DESC
- """)
+
+ query = """
+ SELECT
+ st.*,
+ COUNT(s.id) AS usage_count,
+ COALESCE(SUM(CASE WHEN s.status = 'active' THEN 1 ELSE 0 END), 0) AS active_usage_count
+ FROM service_templates st
+ LEFT JOIN services s ON s.template_id = st.id
+ WHERE 1=1
+ """
+ params = []
+
+ if selected_type:
+ query += " AND st.service_type = %s"
+ params.append(selected_type)
+
+ if selected_active in ("0", "1"):
+ query += " AND st.is_active = %s"
+ params.append(int(selected_active))
+
+ query += """
+ GROUP BY st.id
+ ORDER BY st.id DESC
+ """
+
+ cursor.execute(query, tuple(params))
templates = cursor.fetchall()
conn.close()
- return render_template("service_templates/list.html", templates=templates)
+ return render_template(
+ "service_templates/list.html",
+ templates=templates,
+ selected_type=selected_type,
+ selected_active=selected_active,
+ total_count=len(templates)
+ )
@app.route("/service-templates/new", methods=["GET", "POST"])
@@ -4823,6 +4910,182 @@ def portal_index():
return redirect("/portal/dashboard")
return render_template("portal_login.html")
+
+@app.route("/portal/register", methods=["GET", "POST"])
+def portal_register():
+ if request.method == "GET":
+ return render_template("portal_register.html", error=None, message=None, form_email="", form_company="", form_contact="", form_note="")
+
+ email = (request.form.get("email") or "").strip().lower()
+ company_name = (request.form.get("company_name") or "").strip()
+ contact_name = (request.form.get("contact_name") or "").strip()
+ note = (request.form.get("note") or "").strip()
+
+ if not email or "@" not in email:
+ return render_template(
+ "portal_register.html",
+ error="A valid email address is required.",
+ message=None,
+ form_email=email,
+ form_company=company_name,
+ form_contact=contact_name,
+ form_note=note
+ )
+
+ access_code = generate_portal_access_code()
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ cursor.execute("SELECT id, company_name, contact_name, email FROM clients WHERE LOWER(email) = %s LIMIT 1", (email,))
+ existing = cursor.fetchone()
+
+ if existing:
+ client_id = existing["id"]
+ update_cursor = conn.cursor()
+ update_cursor.execute("""
+ UPDATE clients
+ SET portal_enabled = 1,
+ portal_access_code = %s,
+ portal_access_code_created_at = UTC_TIMESTAMP(),
+ portal_password_hash = NULL,
+ portal_password_set_at = NULL,
+ portal_force_password_change = 1,
+ status = IF(status = 'inactive', 'lead', status),
+ notes = CONCAT(
+ COALESCE(notes, ''),
+ %s
+ )
+ WHERE id = %s
+ """, (
+ access_code,
+ "\n\n[Portal self-signup/request access] Existing client requested access. Note: " + (note or "(none)"),
+ client_id
+ ))
+ conn.commit()
+ else:
+ cursor.execute("SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM clients")
+ row = cursor.fetchone()
+ next_id = int(row["next_id"] or 1)
+ client_code = f"WEB-{next_id:04d}"
+
+ cursor.execute("SELECT COUNT(*) AS c FROM clients WHERE client_code = %s", (client_code,))
+ while cursor.fetchone()["c"]:
+ next_id += 1
+ client_code = f"WEB-{next_id:04d}"
+ cursor.execute("SELECT COUNT(*) AS c FROM clients WHERE client_code = %s", (client_code,))
+
+ display_company = company_name or contact_name or email
+
+ insert_cursor = conn.cursor()
+ insert_cursor.execute("""
+ INSERT INTO clients
+ (
+ client_code,
+ company_name,
+ contact_name,
+ email,
+ status,
+ notes,
+ portal_enabled,
+ portal_access_code,
+ portal_access_code_created_at,
+ portal_password_hash,
+ portal_password_set_at,
+ portal_force_password_change
+ )
+ VALUES (%s, %s, %s, %s, 'lead', %s, 1, %s, UTC_TIMESTAMP(), NULL, NULL, 1)
+ """, (
+ client_code,
+ display_company,
+ contact_name or None,
+ email,
+ "Created by public portal self-signup/request access.\nNote: " + (note or "(none)"),
+ access_code
+ ))
+ conn.commit()
+ client_id = insert_cursor.lastrowid
+
+ conn.close()
+
+ portal_url = "https://otb-billing.outsidethebox.top/portal"
+ subject = "Your OutsideTheBox portal access code"
+ body = f"""Your OutsideTheBox client portal access code is ready.
+
+Portal:
+{portal_url}
+
+Email:
+{email}
+
+Access code:
+{access_code}
+
+After your first successful login, you will be asked to create your password.
+Once your password is created, this access code is cleared and future logins use your email address and password.
+
+If you did not request this, contact support@outsidethebox.top.
+"""
+
+ try:
+ send_configured_email(
+ to_email=email,
+ subject=subject,
+ body=body,
+ email_type="portal_self_signup"
+ )
+ except Exception as exc:
+ print(f"[portal register] email send failed for client_id={client_id}: {exc}")
+ return render_template(
+ "portal_register.html",
+ error="Your account request was created, but the access email could not be sent. Please contact support.",
+ message=None,
+ form_email=email,
+ form_company=company_name,
+ form_contact=contact_name,
+ form_note=note
+ )
+
+ try:
+ admin_subject = f"New portal signup request: {email}"
+ admin_body = f"""A new OutsideTheBox portal signup/request-access form was submitted.
+
+Client ID:
+{client_id}
+
+Email:
+{email}
+
+Company / Organization:
+{company_name or "(not provided)"}
+
+Contact Name:
+{contact_name or "(not provided)"}
+
+Request Note:
+{note or "(none)"}
+
+The user has been emailed a one-time access code and can complete first-login password setup through the portal.
+"""
+ send_configured_email(
+ to_email="info@outsidethebox.top",
+ subject=admin_subject,
+ body=admin_body,
+ email_type="portal_self_signup_admin_notice"
+ )
+ except Exception as exc:
+ print(f"[portal register] admin notification failed for client_id={client_id}: {exc}")
+
+ return render_template(
+ "portal_register.html",
+ error=None,
+ message="Access code sent. Check your email, then return to the portal login page.",
+ form_email=email,
+ form_company="",
+ form_contact="",
+ form_note=""
+ )
+
+
@app.route("/portal/login", methods=["POST"])
def portal_login():
email = (request.form.get("email") or "").strip().lower()
@@ -4889,6 +5152,30 @@ def portal_login():
@app.route("/portal/set-password", methods=["GET", "POST"])
def portal_set_password():
client = _portal_current_client()
+
+ # Allow direct email setup link:
+ # /portal/set-password?email=user@example.com&code=ABC123-DEF456-GHI789
+ if not client and request.method == "GET":
+ email = (request.args.get("email") or "").strip().lower()
+ code = (request.args.get("code") or "").strip()
+
+ if email and code:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+ cursor.execute("""
+ SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code
+ FROM clients
+ WHERE LOWER(email) = %s
+ LIMIT 1
+ """, (email,))
+ row = cursor.fetchone()
+ conn.close()
+
+ if row and row.get("portal_enabled") and (row.get("portal_access_code") or "") == code:
+ session["portal_client_id"] = row["id"]
+ session["portal_email"] = row["email"]
+ client = row
+
if not client:
return redirect("/portal")
@@ -4912,7 +5199,8 @@ def portal_set_password():
SET portal_password_hash = %s,
portal_password_set_at = UTC_TIMESTAMP(),
portal_force_password_change = 0,
- portal_access_code = NULL
+ portal_access_code = NULL,
+ portal_last_login_at = UTC_TIMESTAMP()
WHERE id = %s
""", (generate_password_hash(password), client["id"]))
conn.commit()
@@ -5772,7 +6060,12 @@ Portal URL:
Login email:
{portal_email}
-Single-use access code:
+Click the link below to activate your portal access:
+
+https://portal.outsidethebox.top/portal/set-password?email={portal_email}&code={client.get("portal_access_code")}
+
+If the link does not work, use this access code:
+
{client.get("portal_access_code")}
Important:
@@ -5949,7 +6242,12 @@ Portal URL:
Login email:
{client.get("email")}
-Single-use access code:
+Click the link below to activate your portal access:
+
+https://portal.outsidethebox.top/portal/set-password?email={portal_email}&code={client.get("portal_access_code")}
+
+If the link does not work, use this access code:
+
{new_code}
Important:
diff --git a/bump.sh b/bump.sh
new file mode 100755
index 0000000..d5096d5
--- /dev/null
+++ b/bump.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -e
+
+VERSION="v0.3.0"
+DATE="$(date +%Y-%m-%d)"
+STAMP="$(date +%Y%m%d-%H%M%S)"
+
+echo "===== CLEAN TEMP FILES ====="
+find . -type f \( -name "*.bak*" -o -name "patch*.sh" \)
+
+read -p "Delete these files? Type YES: " CONFIRM
+if [ "$CONFIRM" = "YES" ]; then
+ find . -type f \( -name "*.bak*" -o -name "patch*.sh" \) -delete
+fi
+
+echo "===== SET VERSION ====="
+echo "$VERSION" > VERSION
+
+echo "===== UPDATE PROJECT_STATE.md ====="
+cat <> PROJECT_STATE.md
+
+## $VERSION - $DATE
+- Portal onboarding flow upgraded
+- Email invites now include clickable activation link
+- /portal/set-password now supports direct email+code login
+- Auto session creation from invite link
+- Improved UX: no manual code entry required
+- Portal onboarding now production-ready
+
+STATE
+
+echo "===== UPDATE README.md ====="
+sed -i "1i\\
+## $VERSION ($DATE)\\
+- Clickable portal invite links\\
+- Direct account activation from email\\
+- Improved onboarding UX\\
+" README.md
+
+echo "===== VERIFY PYTHON ====="
+python3 -m py_compile backend/app.py
+
+echo "===== CREATE FULL BACKUP ====="
+zip -r "/home/def/backuphere/otb_billing-$VERSION-$STAMP.zip" . >/dev/null
+
+echo "===== GIT ADD ====="
+git add .
+
+echo "===== GIT COMMIT ====="
+git commit -m "Release $VERSION - Portal onboarding flow complete (email link activation)"
+
+echo "===== GIT TAG ====="
+git tag "$VERSION"
+
+echo "===== GIT PUSH ====="
+git push
+git push origin "$VERSION"
+
+echo "===== DONE ====="
diff --git a/patch.sh b/patch.sh
deleted file mode 100755
index 6d84714..0000000
--- a/patch.sh
+++ /dev/null
@@ -1,1278 +0,0 @@
-cd /home/def/otb_billing || exit 1
-
-cat > /tmp/otb_billing_service_templates_patch.sh <<'EOF'
-#!/usr/bin/env bash
-set -euo pipefail
-
-APP_ROOT="/home/def/otb_billing"
-BACKUP_DIR="/home/def/backuphere"
-STAMP="$(date +%Y%m%d-%H%M%S)"
-
-mkdir -p "$BACKUP_DIR"
-
-echo "===== sanity ====="
-test -f "$APP_ROOT/backend/app.py"
-test -f "$APP_ROOT/templates/services/list.html"
-test -f "$APP_ROOT/templates/services/new.html"
-test -f "$APP_ROOT/templates/services/edit.html"
-
-echo "===== backups ====="
-cp "$APP_ROOT/backend/app.py" "$BACKUP_DIR/app.py.service-templates.${STAMP}.bak"
-cp "$APP_ROOT/templates/services/list.html" "$BACKUP_DIR/services-list.html.service-templates.${STAMP}.bak"
-cp "$APP_ROOT/templates/services/new.html" "$BACKUP_DIR/services-new.html.service-templates.${STAMP}.bak"
-cp "$APP_ROOT/templates/services/edit.html" "$BACKUP_DIR/services-edit.html.service-templates.${STAMP}.bak"
-cp "$APP_ROOT/templates/includes/site_nav.html" "$BACKUP_DIR/site_nav.html.service-templates.${STAMP}.bak" || true
-
-mkdir -p "$APP_ROOT/templates/service_templates"
-
-echo "===== create database table if missing ====="
-sudo mysql -D otb_billing <<'SQL'
-CREATE TABLE IF NOT EXISTS service_templates (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- template_name VARCHAR(255) NOT NULL,
- service_type ENUM('hosting','rpc','explorer','node','ipfs','consulting','crypto_infra','other') NOT NULL DEFAULT 'other',
- billing_cycle ENUM('one_time','monthly','quarterly','yearly','manual') NOT NULL DEFAULT 'monthly',
- currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD',
- recurring_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
- setup_amount DECIMAL(18,8) NOT NULL DEFAULT 0.00000000,
- description TEXT DEFAULT NULL,
- is_active TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP NOT NULL DEFAULT current_timestamp(),
- updated_at TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
- KEY idx_service_templates_active (is_active),
- KEY idx_service_templates_name (template_name)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-SQL
-
-echo "===== patch backend/app.py ====="
-python3 <<'PY'
-from pathlib import Path
-
-path = Path("/home/def/otb_billing/backend/app.py")
-text = path.read_text()
-
-old_new_block = """@app.route("/services/new", methods=["GET", "POST"])
-def new_service():
- gate = admin_required()
- if gate:
- return gate
- 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)
-"""
-
-new_new_block = """@app.route("/services/new", methods=["GET", "POST"])
-def new_service():
- gate = admin_required()
- if gate:
- return gate
- 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()
- cursor.execute(\"\"\"
- SELECT id, template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description
- FROM service_templates
- WHERE is_active = 1
- ORDER BY template_name ASC
- \"\"\")
- templates = cursor.fetchall()
- conn.close()
- return render_template("services/new.html", clients=clients, templates=templates)
-"""
-
-if old_new_block not in text:
- raise SystemExit("Could not find /services/new block to replace.")
-text = text.replace(old_new_block, new_new_block)
-
-old_edit_block = """@app.route("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- gate = admin_required()
- if gate:
- return gate
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute(\"\"\"
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- \"\"\", (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute(\"\"\"
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- \"\"\", (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute(\"\"\"
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- \"\"\", (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, errors=[])
-"""
-
-new_edit_block = """@app.route("/services/edit/", methods=["GET", "POST"])
-def edit_service(service_id):
- gate = admin_required()
- if gate:
- return gate
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- client_id = request.form.get("client_id", "").strip()
- service_name = request.form.get("service_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- status = request.form.get("status", "").strip()
- start_date = request.form.get("start_date", "").strip()
- description = request.form.get("description", "").strip()
-
- errors = []
-
- if not client_id:
- errors.append("Client is required.")
- if not service_name:
- errors.append("Service name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if not recurring_amount:
- errors.append("Recurring amount is required.")
- if not status:
- errors.append("Status is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- except ValueError:
- errors.append("Recurring amount must be a valid number.")
-
- if errors:
- cursor.execute(\"\"\"
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- \"\"\", (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- cursor.execute(\"\"\"
- SELECT id, template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description
- FROM service_templates
- WHERE is_active = 1
- ORDER BY template_name ASC
- \"\"\")
- templates = cursor.fetchall()
-
- conn.close()
- return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute(\"\"\"
- UPDATE services
- SET client_id = %s,
- service_name = %s,
- service_type = %s,
- billing_cycle = %s,
- status = %s,
- currency_code = %s,
- recurring_amount = %s,
- start_date = %s,
- description = %s
- WHERE id = %s
- \"\"\", (
- client_id,
- service_name,
- service_type,
- billing_cycle,
- status,
- currency_code,
- recurring_amount,
- start_date or None,
- description or None,
- service_id
- ))
- conn.commit()
- conn.close()
- return redirect("/services")
-
- cursor.execute(\"\"\"
- SELECT s.*, c.company_name
- FROM services s
- LEFT JOIN clients c ON s.client_id = c.id
- WHERE s.id = %s
- \"\"\", (service_id,))
- service = cursor.fetchone()
-
- cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC")
- clients = cursor.fetchall()
- cursor.execute(\"\"\"
- SELECT id, template_name, service_type, billing_cycle, currency_code, recurring_amount, setup_amount, description
- FROM service_templates
- WHERE is_active = 1
- ORDER BY template_name ASC
- \"\"\")
- templates = cursor.fetchall()
- conn.close()
-
- if not service:
- return "Service not found", 404
-
- return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=[])
-"""
-
-if old_edit_block not in text:
- raise SystemExit("Could not find /services/edit block to replace.")
-text = text.replace(old_edit_block, new_edit_block)
-
-insert_after = """ return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=[])
-
-
-
-
-
-
-@app.route("/invoices/export.csv")
-"""
-new_routes = """ return render_template("services/edit.html", service=service, clients=clients, templates=templates, errors=[])
-
-
-@app.route("/service-templates")
-def service_templates():
- gate = admin_required()
- if gate:
- return gate
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
- cursor.execute(\"\"\"
- SELECT *
- FROM service_templates
- ORDER BY id DESC
- \"\"\")
- templates = cursor.fetchall()
- conn.close()
-
- return render_template("service_templates/list.html", templates=templates)
-
-
-@app.route("/service-templates/new", methods=["GET", "POST"])
-def new_service_template():
- gate = admin_required()
- if gate:
- return gate
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- errors = []
-
- if request.method == "POST":
- template_name = request.form.get("template_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- setup_amount = request.form.get("setup_amount", "").strip()
- description = request.form.get("description", "").strip()
- is_active = 1 if request.form.get("is_active") == "1" else 0
-
- if not template_name:
- errors.append("Template name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if recurring_amount == "":
- errors.append("Recurring amount is required.")
- if setup_amount == "":
- errors.append("Setup amount is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- setup_amount_value = float(setup_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- if setup_amount_value < 0:
- errors.append("Setup amount cannot be negative.")
- except ValueError:
- errors.append("Amounts must be valid numbers.")
-
- if not errors:
- insert_cursor = conn.cursor()
- insert_cursor.execute(\"\"\"
- INSERT INTO service_templates
- (
- template_name,
- service_type,
- billing_cycle,
- currency_code,
- recurring_amount,
- setup_amount,
- description,
- is_active
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
- \"\"\", (
- template_name,
- service_type,
- billing_cycle,
- currency_code,
- recurring_amount,
- setup_amount,
- description or None,
- is_active
- ))
- conn.commit()
- conn.close()
- return redirect("/service-templates")
-
- conn.close()
- return render_template("service_templates/new.html", errors=errors)
-
-
-@app.route("/service-templates/edit/", methods=["GET", "POST"])
-def edit_service_template(template_id):
- gate = admin_required()
- if gate:
- return gate
-
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- if request.method == "POST":
- template_name = request.form.get("template_name", "").strip()
- service_type = request.form.get("service_type", "").strip()
- billing_cycle = request.form.get("billing_cycle", "").strip()
- currency_code = request.form.get("currency_code", "").strip()
- recurring_amount = request.form.get("recurring_amount", "").strip()
- setup_amount = request.form.get("setup_amount", "").strip()
- description = request.form.get("description", "").strip()
- is_active = 1 if request.form.get("is_active") == "1" else 0
-
- errors = []
-
- if not template_name:
- errors.append("Template name is required.")
- if not service_type:
- errors.append("Service type is required.")
- if not billing_cycle:
- errors.append("Billing cycle is required.")
- if not currency_code:
- errors.append("Currency code is required.")
- if recurring_amount == "":
- errors.append("Recurring amount is required.")
- if setup_amount == "":
- errors.append("Setup amount is required.")
-
- if not errors:
- try:
- recurring_amount_value = float(recurring_amount)
- setup_amount_value = float(setup_amount)
- if recurring_amount_value < 0:
- errors.append("Recurring amount cannot be negative.")
- if setup_amount_value < 0:
- errors.append("Setup amount cannot be negative.")
- except ValueError:
- errors.append("Amounts must be valid numbers.")
-
- if errors:
- cursor.execute("SELECT * FROM service_templates WHERE id = %s", (template_id,))
- template = cursor.fetchone()
- conn.close()
- return render_template("service_templates/edit.html", template=template, errors=errors)
-
- update_cursor = conn.cursor()
- update_cursor.execute(\"\"\"
- UPDATE service_templates
- SET template_name = %s,
- service_type = %s,
- billing_cycle = %s,
- currency_code = %s,
- recurring_amount = %s,
- setup_amount = %s,
- description = %s,
- is_active = %s
- WHERE id = %s
- \"\"\", (
- template_name,
- service_type,
- billing_cycle,
- currency_code,
- recurring_amount,
- setup_amount,
- description or None,
- is_active,
- template_id
- ))
- conn.commit()
- conn.close()
- return redirect("/service-templates")
-
- cursor.execute("SELECT * FROM service_templates WHERE id = %s", (template_id,))
- template = cursor.fetchone()
- conn.close()
-
- if not template:
- return "Service template not found", 404
-
- return render_template("service_templates/edit.html", template=template, errors=[])
-
-
-
-
-
-@app.route("/invoices/export.csv")
-"""
-if insert_after not in text:
- raise SystemExit("Could not find insertion point before /invoices/export.csv.")
-text = text.replace(insert_after, new_routes)
-
-path.write_text(text)
-PY
-
-echo "===== rewrite templates/services/list.html ====="
-cat > "$APP_ROOT/templates/services/list.html" <<'HTML'
-
-
-
-Services
-
-
-
-
-Services
-
-Home
-
- Add Service |
- Service Templates
-
-
-
-
- ID
- Service Code
- Client
- Service Name
- Type
- Cycle
- Currency
- Amount
- Status
- Start Date
- Actions
-
-
-{% for s in services %}
-
- {{ s.id }}
- {{ s.service_code }}
- {{ s.client_code }} - {{ s.company_name }}
- {{ s.service_name }}
- {{ s.service_type }}
- {{ s.billing_cycle }}
- {{ s.currency_code }}
- {{ s.recurring_amount|money(s.currency_code) }}
- {{ s.status }}
- {{ s.start_date }}
- Edit
-
-{% endfor %}
-
-
-
-{% include "footer.html" %}
-
-
-HTML
-
-echo "===== rewrite templates/services/new.html ====="
-cat > "$APP_ROOT/templates/services/new.html" <<'HTML'
-
-
-
-New Service
-
-
-
-
-Add Service
-
-Home
-
- Back to Services |
- Service Templates
-
-
-
-
-
-
-{% include "footer.html" %}
-
-
-HTML
-
-echo "===== rewrite templates/services/edit.html ====="
-cat > "$APP_ROOT/templates/services/edit.html" <<'HTML'
-
-
-
-Edit Service
-
-
-
-
-Edit Service
-
-Home
-
- Back to Services |
- Service Templates
-
-
-{% if errors %}
-
-
Please fix the following:
-
- {% for error in errors %}
- {{ error }}
- {% endfor %}
-
-
-{% endif %}
-
-
-
-
-Service Code
-
-
-
-
-Client *
-
- Select client
- {% for c in clients %}
-
- {{ c.client_code }} - {{ c.company_name }}
-
- {% endfor %}
-
-
-
-
-Load from Template
-
- -- select template --
- {% for t in templates %}
-
- {{ t.template_name }} ({{ t.recurring_amount|money(t.currency_code) }}{% if t.setup_amount and t.setup_amount != 0 %}, setup {{ t.setup_amount|money(t.currency_code) }}{% endif %})
-
- {% endfor %}
-
-
-
-
-Service Name *
-
-
-
-
-Service Type *
-
- hosting
- rpc
- explorer
- node
- ipfs
- consulting
- Crypto Infra
- other
-
-
-
-
-Billing Cycle *
-
- one_time
- monthly
- quarterly
- yearly
- manual
-
-
-
-
-Currency Code *
-
- CAD
- ETHO
- EGAZ
- ALT
-
-
-
-
-Recurring Amount *
-
-
-
-
-Status *
-
- pending
- active
- suspended
- cancelled
-
-
-
-
-Start Date
-
-
-
-
-Description
-{{ service.description or '' }}
-
-
-
-Save Service
-
-
-
-
-
-
-{% include "footer.html" %}
-
-
-HTML
-
-echo "===== create templates/service_templates/list.html ====="
-cat > "$APP_ROOT/templates/service_templates/list.html" <<'HTML'
-
-
-
-Service Templates
-
-
-
-
-Service Templates
-
-Home
-
- Back to Services |
- Add Service Template
-
-
-
-
- ID
- Template Name
- Type
- Cycle
- Currency
- Recurring
- Setup
- Active
- Actions
-
-
-{% for t in templates %}
-
- {{ t.id }}
- {{ t.template_name }}
- {{ t.service_type }}
- {{ t.billing_cycle }}
- {{ t.currency_code }}
- {{ t.recurring_amount|money(t.currency_code) }}
- {{ t.setup_amount|money(t.currency_code) }}
- {% if t.is_active %}yes{% else %}no{% endif %}
- Edit
-
-{% endfor %}
-
-
-
-{% include "footer.html" %}
-
-
-HTML
-
-echo "===== create templates/service_templates/new.html ====="
-cat > "$APP_ROOT/templates/service_templates/new.html" <<'HTML'
-
-
-
-New Service Template
-
-
-
-
-Add Service Template
-
-Home
-Back to Service Templates
-
-{% if errors %}
-
-
Please fix the following:
-
- {% for error in errors %}
- {{ error }}
- {% endfor %}
-
-
-{% endif %}
-
-
-
-
-Template Name *
-
-
-
-
-Service Type *
-
- hosting
- rpc
- explorer
- node
- ipfs
- consulting
- Crypto Infra
- other
-
-
-
-
-Billing Cycle *
-
- one_time
- monthly
- quarterly
- yearly
- manual
-
-
-
-
-Currency Code *
-
- CAD
- ETHO
- EGAZ
- ALT
-
-
-
-
-Recurring Amount *
-
-
-
-
-Setup Amount *
-
-
-
-
-Description
-
-
-
-
-Active
-
- yes
- no
-
-
-
-
-Create Service Template
-
-
-
-
-{% include "footer.html" %}
-
-
-HTML
-
-echo "===== create templates/service_templates/edit.html ====="
-cat > "$APP_ROOT/templates/service_templates/edit.html" <<'HTML'
-
-
-
-Edit Service Template
-
-
-
-
-Edit Service Template
-
-Home
-Back to Service Templates
-
-{% if errors %}
-
-
Please fix the following:
-
- {% for error in errors %}
- {{ error }}
- {% endfor %}
-
-
-{% endif %}
-
-
-
-
-Template Name *
-
-
-
-
-Service Type *
-
- hosting
- rpc
- explorer
- node
- ipfs
- consulting
- Crypto Infra
- other
-
-
-
-
-Billing Cycle *
-
- one_time
- monthly
- quarterly
- yearly
- manual
-
-
-
-
-Currency Code *
-
- CAD
- ETHO
- EGAZ
- ALT
-
-
-
-
-Recurring Amount *
-
-
-
-
-Setup Amount *
-
-
-
-
-Description
-{{ template.description or '' }}
-
-
-
-Active
-
- yes
- no
-
-
-
-
-Save Service Template
-
-
-
-
-{% include "footer.html" %}
-
-
-HTML
-
-echo "===== verify syntax ====="
-python3 -m py_compile "$APP_ROOT/backend/app.py"
-
-echo "===== restart service ====="
-sudo systemctl restart otb_billing.service
-sleep 2
-sudo systemctl --no-pager --full status otb_billing.service | sed -n '1,35p'
-
-echo "===== verify routes present ====="
-grep -nE '@app.route\("/service-templates"|def service_templates|def new_service_template|def edit_service_template' "$APP_ROOT/backend/app.py"
-
-echo "===== verify template table ====="
-sudo mysql -D otb_billing -e "SHOW TABLES LIKE 'service_templates';"
-sudo mysql -D otb_billing -e "DESCRIBE service_templates;"
-
-echo "===== completed ====="
-echo "Backups saved under: $BACKUP_DIR"
-EOF
-
-chmod +x /tmp/otb_billing_service_templates_patch.sh
-/tmp/otb_billing_service_templates_patch.sh
diff --git a/patch1.sh b/patch1.sh
deleted file mode 100755
index a9c5bfa..0000000
--- a/patch1.sh
+++ /dev/null
@@ -1,70 +0,0 @@
-cd /home/def/otb_billing || exit 1
-
-STAMP=$(date +%Y%m%d-%H%M%S)
-NEWVER="v0.6.1"
-
-echo "===== backup full project ====="
-mkdir -p /home/def/backuphere
-cd /home/def
-tar -czf /home/def/backuphere/otb_billing-${NEWVER}-${STAMP}.tar.gz otb_billing
-
-echo "===== update VERSION ====="
-cd /home/def/otb_billing || exit 1
-echo "${NEWVER}" > VERSION
-
-echo "===== update README.md ====="
-cp README.md /home/def/backuphere/README.md.${STAMP}.bak
-
-cat > /tmp/readme_entry.txt < README.md.new
-mv README.md.new README.md
-
-echo "===== update PROJECT_STATE.md ====="
-cp PROJECT_STATE.md /home/def/backuphere/PROJECT_STATE.md.${STAMP}.bak
-
-cat > /tmp/state_entry.txt < PROJECT_STATE.md.new
-mv PROJECT_STATE.md.new PROJECT_STATE.md
-
-echo "===== git status ====="
-git status
-
-echo "===== commit ====="
-git add .
-git commit -m "bump ${NEWVER} - add service templates system"
-
-echo "===== push ====="
-git push
-
-echo "===== done ====="
-echo "Backup saved at:"
-ls -lh /home/def/backuphere/otb_billing-${NEWVER}-${STAMP}.tar.gz
diff --git a/scripts/invoice_reminder_worker.py.bak_20260313-035553 b/scripts/invoice_reminder_worker.py.bak_20260313-035553
deleted file mode 100755
index f54c292..0000000
--- a/scripts/invoice_reminder_worker.py.bak_20260313-035553
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env python3
-
-import sys
-from datetime import datetime, timedelta
-
-sys.path.append("/home/def/otb_billing/backend")
-
-from app import get_db_connection, send_configured_email
-
-REMINDER_DAYS = 7
-OVERDUE_DAYS = 14
-
-
-def main():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- now = datetime.utcnow()
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.created_at,
- i.total,
- i.client_id,
- c.email,
- c.company_name,
- c.contact_name
- FROM invoices i
- JOIN clients c ON c.id = i.client_id
- WHERE i.status IN ('pending','sent')
- """)
-
- invoices = cursor.fetchall()
-
- for inv in invoices:
- age = (now - inv["created_at"]).days
-
- email = inv["email"]
- if not email:
- continue
-
- name = inv.get("contact_name") or inv.get("company_name") or "Client"
-
- portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}"
-
- if age >= OVERDUE_DAYS:
-
- subject = f"Invoice {inv['invoice_number']} is overdue"
-
- body = f"""
-Hello {name},
-
-Invoice {inv['invoice_number']} is now overdue.
-
-Amount Due:
-{inv['total']}
-
-View invoice:
-{portal_url}
-
-Please arrange payment at your earliest convenience.
-
-OutsideTheBox
-"""
-
- send_configured_email(
- to_email=email,
- subject=subject,
- body=body,
- attachments=None,
- email_type="invoice_overdue",
- invoice_id=inv["id"]
- )
-
- elif age >= REMINDER_DAYS:
-
- subject = f"Invoice {inv['invoice_number']} reminder"
-
- body = f"""
-Hello {name},
-
-This is a reminder that invoice {inv['invoice_number']} is still outstanding.
-
-Amount Due:
-{inv['total']}
-
-View invoice:
-{portal_url}
-
-Thank you.
-
-OutsideTheBox
-"""
-
- send_configured_email(
- to_email=email,
- subject=subject,
- body=body,
- attachments=None,
- email_type="invoice_reminder",
- invoice_id=inv["id"]
- )
-
- conn.close()
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/invoice_reminder_worker.py.bak_20260313-035724 b/scripts/invoice_reminder_worker.py.bak_20260313-035724
deleted file mode 100755
index 093c955..0000000
--- a/scripts/invoice_reminder_worker.py.bak_20260313-035724
+++ /dev/null
@@ -1,117 +0,0 @@
-#!/usr/bin/env python3
-
-
-import sys
-import os
-from datetime import datetime, timedelta
-from dotenv import load_dotenv
-
-# load same environment config as Flask
-load_dotenv("/home/def/otb_billing/.env")
-
-sys.path.append("/home/def/otb_billing/backend")
-
-from app import get_db_connection, send_configured_email
-
-
-REMINDER_DAYS = 7
-OVERDUE_DAYS = 14
-
-
-def main():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- now = datetime.utcnow()
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.created_at,
- i.total,
- i.client_id,
- c.email,
- c.company_name,
- c.contact_name
- FROM invoices i
- JOIN clients c ON c.id = i.client_id
- WHERE i.status IN ('pending','sent')
- """)
-
- invoices = cursor.fetchall()
-
- for inv in invoices:
- age = (now - inv["created_at"]).days
-
- email = inv["email"]
- if not email:
- continue
-
- name = inv.get("contact_name") or inv.get("company_name") or "Client"
-
- portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}"
-
- if age >= OVERDUE_DAYS:
-
- subject = f"Invoice {inv['invoice_number']} is overdue"
-
- body = f"""
-Hello {name},
-
-Invoice {inv['invoice_number']} is now overdue.
-
-Amount Due:
-{inv['total']}
-
-View invoice:
-{portal_url}
-
-Please arrange payment at your earliest convenience.
-
-OutsideTheBox
-"""
-
- send_configured_email(
- to_email=email,
- subject=subject,
- body=body,
- attachments=None,
- email_type="invoice_overdue",
- invoice_id=inv["id"]
- )
-
- elif age >= REMINDER_DAYS:
-
- subject = f"Invoice {inv['invoice_number']} reminder"
-
- body = f"""
-Hello {name},
-
-This is a reminder that invoice {inv['invoice_number']} is still outstanding.
-
-Amount Due:
-{inv['total']}
-
-View invoice:
-{portal_url}
-
-Thank you.
-
-OutsideTheBox
-"""
-
- send_configured_email(
- to_email=email,
- subject=subject,
- body=body,
- attachments=None,
- email_type="invoice_reminder",
- invoice_id=inv["id"]
- )
-
- conn.close()
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/invoice_reminder_worker.py.bak_20260313-041145 b/scripts/invoice_reminder_worker.py.bak_20260313-041145
deleted file mode 100755
index ca92132..0000000
--- a/scripts/invoice_reminder_worker.py.bak_20260313-041145
+++ /dev/null
@@ -1,116 +0,0 @@
-#!/usr/bin/env python3
-
-
-import sys
-import os
-from datetime import datetime, timedelta
-from dotenv import load_dotenv
-
-# load same environment config as Flask
-load_dotenv("/home/def/otb_billing/.env")
-
-sys.path.append("/home/def/otb_billing/backend")
-
-from app import get_db_connection, send_configured_email
-
-
-REMINDER_DAYS = 7
-OVERDUE_DAYS = 14
-
-
-def main():
- conn = get_db_connection()
- cursor = conn.cursor(dictionary=True)
-
- now = datetime.utcnow()
-
- cursor.execute("""
- SELECT
- i.id,
- i.invoice_number,
- i.created_at,
- i.client_id,
- c.email,
- c.company_name,
- c.contact_name
- FROM invoices i
- JOIN clients c ON c.id = i.client_id
- WHERE i.status IN ('pending','sent')
- """)
-
- invoices = cursor.fetchall()
-
- for inv in invoices:
- age = (now - inv["created_at"]).days
-
- email = inv["email"]
- if not email:
- continue
-
- name = inv.get("contact_name") or inv.get("company_name") or "Client"
-
- portal_url = f"https://portal.outsidethebox.top/portal/invoice/{inv['id']}"
-
- if age >= OVERDUE_DAYS:
-
- subject = f"Invoice {inv['invoice_number']} is overdue"
-
- body = f"""
-Hello {name},
-
-Invoice {inv['invoice_number']} is now overdue.
-
-Amount Due:
-Invoice amount available in portal
-
-View invoice:
-{portal_url}
-
-Please arrange payment at your earliest convenience.
-
-OutsideTheBox
-"""
-
- send_configured_email(
- to_email=email,
- subject=subject,
- body=body,
- attachments=None,
- email_type="invoice_overdue",
- invoice_id=inv["id"]
- )
-
- elif age >= REMINDER_DAYS:
-
- subject = f"Invoice {inv['invoice_number']} reminder"
-
- body = f"""
-Hello {name},
-
-This is a reminder that invoice {inv['invoice_number']} is still outstanding.
-
-Amount Due:
-Invoice amount available in portal
-
-View invoice:
-{portal_url}
-
-Thank you.
-
-OutsideTheBox
-"""
-
- send_configured_email(
- to_email=email,
- subject=subject,
- body=body,
- attachments=None,
- email_type="invoice_reminder",
- invoice_id=inv["id"]
- )
-
- conn.close()
-
-
-if __name__ == "__main__":
- main()
diff --git a/templates/portal/terms.html b/templates/portal/terms.html
new file mode 100644
index 0000000..485499e
--- /dev/null
+++ b/templates/portal/terms.html
@@ -0,0 +1,83 @@
+{% extends "portal_base.html" %}
+
+{% block content %}
+
+
+
Outsidethebox.top Service Agreement (v1.1)
+
+
You must read and accept this agreement before using the portal.
+
+
1. Nature of Services
+
+ Outsidethebox.top provides a range of digital services including, but not limited to:
+
+
+ Data storage and file backup services
+ Video and image processing and conversion
+ GPS and location-based tracking applications
+ Web hosting and infrastructure services
+ Custom tools and SaaS-style applications
+
+
+
+ These services may involve the collection, storage, processing, and transmission of user data as required for proper functionality.
+
+
+
2. Use of Services
+
+ Each service provided by Outsidethebox.top may have specific operational requirements, including data handling, processing, or tracking capabilities.
+
+
+
+ By using any service, you acknowledge and accept the requirements necessary for that service to function.
+
+
+
+ If you do not agree with the requirements of a specific service, you must not use that service.
+
+
+
3. User Responsibility
+
+ You are responsible for:
+
+
+ Understanding the purpose and function of each service you use
+ Ensuring you have appropriate authorization for any data you upload or process
+ Choosing not to use services that conflict with your privacy or operational preferences
+
+
+
4. Data Handling
+
+ Outsidethebox.top systems may store, process, and transmit data as required to deliver services. This may include files, metadata, and service-related information.
+
+
+
5. Service-Specific Agreements
+
+ Certain services may present additional agreements or notices before use. These must be accepted before accessing those services.
+
+
+
6. Acceptance
+
+ By proceeding, you confirm that:
+
+
+
+ You understand the nature of the services provided
+ You accept that services may require data processing or tracking to function
+ You will not use services whose requirements you do not accept
+
+
+
+
+
+ I understand and accept the Outsidethebox.top account and service requirements.
+
+
+
+
+ Continue
+ Logout
+
+
+
+{% endblock %}
diff --git a/templates/portal_invoice_detail.html.bak_20260314-020444 b/templates/portal_invoice_detail.html.bak_20260314-020444
deleted file mode 100644
index 1bb442b..0000000
--- a/templates/portal_invoice_detail.html.bak_20260314-020444
+++ /dev/null
@@ -1,209 +0,0 @@
-
-
-
-
-
- Invoice Detail - OutsideTheBox
-
-
-
-
-
-
-
-
-
Invoice Detail
-
{{ client.company_name or client.contact_name or client.email }}
-
-
-
-
-
-
-
Invoice
-
{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
-
-
-
Status
- {% set s = (invoice.status or "")|lower %}
- {% if s == "paid" %}
- {{ invoice.status }}
- {% elif s == "pending" %}
- {{ invoice.status }}
- {% elif s == "overdue" %}
- {{ invoice.status }}
- {% else %}
- {{ invoice.status }}
- {% endif %}
-
-
-
Created
-
{{ invoice.created_at }}
-
-
-
Total
-
{{ invoice.total_amount }}
-
-
-
Paid
-
{{ invoice.amount_paid }}
-
-
-
Outstanding
-
{{ invoice.outstanding }}
-
-
-
-
Invoice Items
-
-
-
- Description
- Qty
- Unit Price
- Line Total
-
-
-
- {% for item in items %}
-
- {{ item.description }}
- {{ item.quantity }}
- {{ item.unit_price }}
- {{ item.line_total }}
-
- {% else %}
-
- No invoice line items found.
-
- {% endfor %}
-
-
-
- {% if pdf_url %}
-
- {% endif %}
-
-
-{% include "footer.html" %}
-
-
-
-Payment Instructions
-
-Interac e-Transfer
-Send payment to:
-payment@outsidethebox.top
-Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
-
-
-Credit Card (Square)
-
-
-
-
Credit Card (Square)
-
-
-Pay with Card (Square)
-
-
-
-You will be redirected to Square's secure payment page.
-Please include your invoice number in the payment note.
-
-
-
-
-If you have questions please contact
-support@outsidethebox.top
-
-
-
-
-
diff --git a/templates/portal_login.html b/templates/portal_login.html
index 889572e..08f6a45 100644
--- a/templates/portal_login.html
+++ b/templates/portal_login.html
@@ -36,6 +36,12 @@
+
+ Need an account?
+ Create portal access
+
+
+
diff --git a/templates/portal_register.html b/templates/portal_register.html
new file mode 100644
index 0000000..0f53410
--- /dev/null
+++ b/templates/portal_register.html
@@ -0,0 +1,46 @@
+{% extends "portal_base.html" %}
+
+{% block title %}Create Portal Access - OutsideTheBox{% endblock %}
+
+{% block portal_content %}
+
+
Request Portal Access
+
Enter your details and we will email you a one-time access code. After first login, you will set your password.
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+ {% if message %}
+
{{ message }}
+
Return to portal login
+ {% else %}
+
+
+ Email Address *
+
+
+
+
+ Company / Organization
+
+
+
+
+ Your Name
+
+
+
+
+ What are you requesting?
+ {{ form_note or '' }}
+
+
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/templates/service_templates/edit.html b/templates/service_templates/edit.html
index f7c0d7b..cc8a5b7 100644
--- a/templates/service_templates/edit.html
+++ b/templates/service_templates/edit.html
@@ -33,10 +33,6 @@ Template Name *
Service Type *
hosting
- rpc
- explorer
- node
- ipfs
consulting
Crypto Infra
other
diff --git a/templates/service_templates/list.html b/templates/service_templates/list.html
index 3a5d028..1bcf4e7 100644
--- a/templates/service_templates/list.html
+++ b/templates/service_templates/list.html
@@ -14,6 +14,43 @@
Add Service Template
+
+ Filters
+
+
+ Service Type
+
+ All
+ hosting
+ crypto_infra
+ saas
+ consulting
+ other
+
+
+
+
+ Active
+
+ All
+ yes
+ no
+
+
+
+
+ Apply Filters
+ Clear
+
+
+
+
+ Showing {{ total_count }} template{% if total_count != 1 %}s{% endif %}
+ {% if selected_type %} | Type: {{ selected_type }} {% endif %}
+ {% if selected_active == '1' %} | Active: yes {% endif %}
+ {% if selected_active == '0' %} | Active: no {% endif %}
+
+
ID
@@ -23,6 +60,8 @@
Currency
Recurring
Setup
+ Used By
+ Active Used By
Active
Actions
@@ -36,11 +75,24 @@
{{ t.currency_code }}
{{ t.recurring_amount|money(t.currency_code) }}
{{ t.setup_amount|money(t.currency_code) }}
+ {{ t.usage_count }}
+ {{ t.active_usage_count }}
{% if t.is_active %}yes{% else %}no{% endif %}
- Edit
+
+ Edit
+ {% if t.is_active %}
+ | Create Service
+ {% endif %}
+
{% endfor %}
+{% if not templates %}
+
+ No service templates matched the selected filters.
+
+{% endif %}
+
{% include "footer.html" %}
diff --git a/templates/service_templates/new.html b/templates/service_templates/new.html
index 2468967..62d97ba 100644
--- a/templates/service_templates/new.html
+++ b/templates/service_templates/new.html
@@ -33,12 +33,9 @@ Template Name *
Service Type *
hosting
- rpc
- explorer
- node
- ipfs
consulting
Crypto Infra
+ SaaS
other
diff --git a/templates/services/edit.html b/templates/services/edit.html
index 9aca2df..34cb1ab 100644
--- a/templates/services/edit.html
+++ b/templates/services/edit.html
@@ -26,6 +26,7 @@
{% endif %}
+
Service Code
@@ -58,6 +59,7 @@ Load from Template
data-recurring="{{ t.recurring_amount }}"
data-setup="{{ t.setup_amount }}"
data-description="{{ (t.description or '')|e }}"
+ {% if service.template_id == t.id %}selected{% endif %}
>
{{ t.template_name }} ({{ t.recurring_amount|money(t.currency_code) }}{% if t.setup_amount and t.setup_amount != 0 %}, setup {{ t.setup_amount|money(t.currency_code) }}{% endif %})
@@ -74,10 +76,6 @@ Service Name *
Service Type *
hosting
- rpc
- explorer
- node
- ipfs
consulting
Crypto Infra
other
@@ -139,12 +137,17 @@ Description
diff --git a/templates/settings.html.bak_logo_layout_fix b/templates/settings.html.bak_logo_layout_fix
deleted file mode 100644
index 61c6f7f..0000000
--- a/templates/settings.html.bak_logo_layout_fix
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-Settings
-
-
-
-
-Settings / Config
-
-Home
-
-
-
-
-
- Save Settings
-
-
-
-{% include "footer.html" %}
-
-