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 -

- - - - - - - - - - - - - - - - -{% for s in services %} - - - - - - - - - - - - - -{% endfor %} - -
IDService CodeClientService NameTypeCycleCurrencyAmountStatusStart DateActions
{{ 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
- -{% 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 -

- -
- -

-Client
- -

- -

-Load from Template
- -

- -

-Service Name
- -

- -

-Service Type
- -

- -

-Billing Cycle
- -

- -

-Currency Code
- -

- -

-Recurring Amount
- -

- -

-Status
- -

- -

-Start Date
- -

- -

-Description
- -

- -

- -

- -
- - - -{% 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 *
- -

- -

-Load from Template
- -

- -

-Service Name *
- -

- -

-Service Type *
- -

- -

-Billing Cycle *
- -

- -

-Currency Code *
- -

- -

-Recurring Amount *
- -

- -

-Status *
- -

- -

-Start Date
- -

- -

-Description
- -

- -

- -

- -
- - - -{% 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 -

- - - - - - - - - - - - - - -{% for t in templates %} - - - - - - - - - - - -{% endfor %} - -
IDTemplate NameTypeCycleCurrencyRecurringSetupActiveActions
{{ 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
- -{% 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 *
- -

- -

-Billing Cycle *
- -

- -

-Currency Code *
- -

- -

-Recurring Amount *
- -

- -

-Setup Amount *
- -

- -

-Description
- -

- -

-Active
- -

- -

- -

- -
- -{% 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 *
- -

- -

-Billing Cycle *
- -

- -

-Currency Code *
- -

- -

-Recurring Amount *
- -

- -

-Setup Amount *
- -

- -

-Description
- -

- -

-Active
- -

- -

- -

- -
- -{% 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
  • +
+ +
+ + +

+ + + 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

- - - - - - - - - - - {% for item in items %} - - - - - - - {% else %} - - - - {% endfor %} - -
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.
- - {% 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 %} +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ + Back to Login +
+
+ {% 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 *
+ + + + + + + +

+ +

+ Active
+ +

+ +

+ + 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 %} +

+ @@ -23,6 +60,8 @@ + + @@ -36,11 +75,24 @@ + + - + {% endfor %} +{% if not templates %} + + + +{% endif %} +
IDCurrency Recurring SetupUsed ByActive Used By Active Actions
{{ 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 %} +
No service templates matched the selected filters.
{% 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 *

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 *

-Example: /static/logo.png or https://site.com/logo.png

- -
- - Slogan / Tagline
-
- - Business Email
-
- - Business Phone
-
- - Business Address
-
- - Website
-
- - Business Number / Registration Number
-
- - Default Currency
- - - -

-

Tax Settings

- - Local Country
-
- - Tax Label
-
- - Tax Rate (%)
-
- - Tax Number
-
- -
- -
- - Payment Terms
-
- - Invoice Footer
-
-
- -
-

Email / SMTP

- - SMTP Host
-
- - SMTP Port
-
- - SMTP Username
-
- - SMTP Password
-
- - From Email
-
- - From Name
-
- -
- -
- -
- -
-
- -
-

Notes

-

- These settings become the identity and delivery configuration for this installation. -

-

- Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them. -

-

- Tax settings are also stored now so invoice and automation logic can use them later. -

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