From 31037858ca4d88063cf94c40d76af6157405c243 Mon Sep 17 00:00:00 2001 From: def Date: Sat, 14 Mar 2026 22:02:14 +0000 Subject: [PATCH] v0.5.0 - Square auto-payment, reconciliation, accountbook, reminder timer --- PROJECT_STATE.md | 36 +- README.md | 33 + VERSION | 2 +- backend/app.py | 1347 +++- backend/app.py.deduped_candidate | 3312 +++++++++ backend/app_cleanup_test.py | 6503 +++++++++++++++++ backend/app_deduped_test.py | 3312 +++++++++ docs/db_reset_rebuild_reference.md | 101 + scripts/invoice_reminder_worker.py | 123 + ...ice_reminder_worker.py.bak_20260313-035553 | 110 + ...ice_reminder_worker.py.bak_20260313-035724 | 117 + ...ice_reminder_worker.py.bak_20260313-041145 | 116 + templates/base.html | 1 + templates/clients/edit.html | 74 + templates/clients/list.html | 1 + templates/clients/new.html | 1 + templates/credits/add.html | 1 + templates/credits/list.html | 1 + templates/dashboard.html | 3 + templates/health.html | 3 +- templates/invoices/edit.html | 1 + templates/invoices/list.html | 1 + templates/invoices/new.html | 1 + templates/invoices/print_batch.html | 1 + templates/invoices/view.html | 53 + ...iew.html.square_button_20260313-055733.bak | 256 + templates/payments/edit.html | 1 + templates/payments/list.html | 1 + templates/payments/new.html | 1 + templates/portal_dashboard.html | 4 +- templates/portal_forgot_password.html | 82 + templates/portal_invoice_detail.html | 36 +- ...al_invoice_detail.html.bak_20260314-020444 | 209 + ...ail.html.square_button_20260313-055733.bak | 187 + templates/portal_login.html | 9 +- templates/portal_set_password.html | 3 +- templates/reports/aging.html | 1 + templates/reports/revenue.html | 1 + templates/reports/revenue_print.html | 1 + templates/services/edit.html | 1 + templates/services/list.html | 1 + templates/services/new.html | 1 + templates/settings.html | 1 + templates/subscriptions/list.html | 1 + templates/subscriptions/new.html | 1 + 45 files changed, 16032 insertions(+), 20 deletions(-) create mode 100644 backend/app.py.deduped_candidate create mode 100644 backend/app_cleanup_test.py create mode 100644 backend/app_deduped_test.py create mode 100644 docs/db_reset_rebuild_reference.md create mode 100755 scripts/invoice_reminder_worker.py create mode 100755 scripts/invoice_reminder_worker.py.bak_20260313-035553 create mode 100755 scripts/invoice_reminder_worker.py.bak_20260313-035724 create mode 100755 scripts/invoice_reminder_worker.py.bak_20260313-041145 create mode 100644 templates/invoices/view.html.square_button_20260313-055733.bak create mode 100644 templates/portal_forgot_password.html create mode 100644 templates/portal_invoice_detail.html.bak_20260314-020444 create mode 100644 templates/portal_invoice_detail.html.square_button_20260313-055733.bak diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 981c1a2..7ed4862 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,16 +1,26 @@ Project: OTB Billing -Version: v0.4.2 -Last Updated: 2026-03-12 -Status: Stable post-dedupe checkpoint +Version: v0.4.3 +Last Updated: 2026-03-13 +Status: Portal lifecycle complete -Current State: -- backend/app.py deduped and running cleanly under systemd. -- Portal supports email + one-time access code, forced password setup, dashboard, invoice detail, and secure PDF access. -- New/editable invoices write invoice_items automatically. -- Public portal host: portal.outsidethebox.top -- Billing host: otb-billing.outsidethebox.top +Current capabilities: +- Admin can enable/disable portal access +- Admin can generate/reset one-time access codes +- Admin can send portal invite email +- Admin can send portal password reset email + +Client portal features: +- First login via single-use access code +- Forced password creation +- Email + password authentication after setup +- Invoice dashboard +- Invoice detail page +- Secure invoice PDF downloads + +Infrastructure: +- Flask backend running via systemd +- MariaDB backend +- SMTP email integration +- Portal domain: portal.outsidethebox.top +- Billing admin: otb-billing.outsidethebox.top -Operations: -- sudo systemctl status otb_billing -- sudo systemctl restart otb_billing -- sudo journalctl -u otb_billing -f diff --git a/README.md b/README.md index 9a89513..06390e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ +## v0.5.0 - 2026-03-14 22:01:59 + +- Added per-invoice Square payment links +- Added Square webhook validation and automatic invoice payment application +- Added duplicate webhook protection +- Added Square reconciliation page with filters and summary cards +- Added Accountbook page with today / month / YTD totals +- Added Accountbook CSV export +- Added reminder worker logging plus systemd service/timer +- Confirmed end-to-end automatic Square payment flow updates invoice, payments table, portal state, and email notifications + +## v0.4.3 - 2026-03-13 + +Portal lifecycle features completed. + +New functionality: +- Portal invite email from admin panel +- Portal password reset email from admin panel +- Single-use access code behavior clarified and enforced +- Portal password reset invalidates previous credentials +- Admin controls for portal enable/disable and code reset +- Portal access wording updated to reflect single-use token design + +Existing functionality confirmed: +- Client portal login +- Forced password creation +- Invoice dashboard +- Invoice detail view +- PDF invoice download +- Deduplicated backend/app.py + +This version represents the first **complete client portal credential lifecycle**. + ## v0.4.2 - 2026-03-12 - Deduped backend/app.py and removed duplicated major route/function sections. - Removed the text_for_pdf_routes snapshot hack from active runtime path. diff --git a/VERSION b/VERSION index 0eec13e..b043aa6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4.2 +v0.5.0 diff --git a/backend/app.py b/backend/app.py index 421ef24..4c2d6a3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,5 +1,5 @@ import os -from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session, Response from db import get_db_connection from utils import generate_client_code, generate_service_code from datetime import datetime, timezone, date, timedelta @@ -11,8 +11,17 @@ from dateutil.relativedelta import relativedelta from io import BytesIO, StringIO import csv +import json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import uuid +import re import zipfile import smtplib +import secrets from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader @@ -31,6 +40,12 @@ LOCAL_TZ = ZoneInfo("America/Toronto") BASE_DIR = Path(__file__).resolve().parent.parent app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +SQUARE_ACCESS_TOKEN = os.getenv("SQUARE_ACCESS_TOKEN", "") +SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") +SQUARE_WEBHOOK_NOTIFICATION_URL = os.getenv("SQUARE_WEBHOOK_NOTIFICATION_URL", "") +SQUARE_API_BASE = "https://connect.squareup.com" +SQUARE_API_VERSION = "2026-01-22" +SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") @@ -78,6 +93,97 @@ def fmt_money(value, currency_code="CAD"): return f"{amount:.8f}" + + + +def square_amount_to_cents(value): + return int((to_decimal(value) * 100).quantize(Decimal("1"))) + +def create_square_payment_link_for_invoice(invoice_row, buyer_email=""): + if not SQUARE_ACCESS_TOKEN: + raise RuntimeError("Square access token is not configured") + + invoice_number = invoice_row.get("invoice_number") or f"INV-{invoice_row.get('id')}" + currency_code = invoice_row.get("currency_code") or "CAD" + amount_cents = square_amount_to_cents(invoice_row.get("total_amount") or "0") + location_id = "1TSPHT78106WX" + + payload = { + "idempotency_key": str(uuid.uuid4()), + "description": f"OTB Billing invoice {invoice_number}", + "quick_pay": { + "name": f"Invoice {invoice_number}", + "price_money": { + "amount": amount_cents, + "currency": currency_code + }, + "location_id": location_id + }, + "payment_note": f"Invoice {invoice_number}", + "checkout_options": { + "redirect_url": "https://portal.outsidethebox.top/portal" + } + } + + if buyer_email: + payload["pre_populated_data"] = { + "buyer_email": buyer_email + } + + req = urllib.request.Request( + f"{SQUARE_API_BASE}/v2/online-checkout/payment-links", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {SQUARE_ACCESS_TOKEN}", + "Square-Version": SQUARE_API_VERSION, + "Content-Type": "application/json" + }, + method="POST" + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Square payment link creation failed: {e.code} {body}") + + payment_link = (data or {}).get("payment_link") or {} + url = payment_link.get("url") + if not url: + raise RuntimeError(f"Square payment link response missing URL: {data}") + + return url + + +def square_signature_is_valid(signature_header, raw_body, notification_url): + if not SQUARE_WEBHOOK_SIGNATURE_KEY or not signature_header: + return False + message = notification_url.encode("utf-8") + raw_body + digest = hmac.new( + SQUARE_WEBHOOK_SIGNATURE_KEY.encode("utf-8"), + message, + hashlib.sha256 + ).digest() + computed_signature = base64.b64encode(digest).decode("utf-8") + return hmac.compare_digest(computed_signature, signature_header) + +def append_square_webhook_log(entry): + try: + log_path = Path(SQUARE_WEBHOOK_LOG) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + 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) + def refresh_overdue_invoices(): conn = get_db_connection() cursor = conn.cursor() @@ -2868,6 +2974,70 @@ def new_payment(): recalc_invoice_totals(invoice_id) + try: + notify_conn = get_db_connection() + notify_cursor = notify_conn.cursor(dictionary=True) + notify_cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.currency_code, + c.company_name, + c.contact_name, + c.email + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.id = %s + LIMIT 1 + """, (invoice_id,)) + invoice_email_row = notify_cursor.fetchone() + notify_conn.close() + + if invoice_email_row and invoice_email_row.get("email"): + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + subject = f"Payment Received for Invoice {invoice_email_row.get('invoice_number')}" + body = f"""Hello {client_name}, + +We have received your payment for invoice {invoice_email_row.get('invoice_number')}. + +Amount Received: +{payment_amount_display} + +Invoice Total: +{invoice_total_display} + +Current Invoice Status: +{invoice_email_row.get('status')} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=None, + email_type="payment_received", + invoice_id=invoice_id + ) + except Exception: + pass + return redirect("/payments") cursor.execute(""" @@ -3161,6 +3331,49 @@ def portal_set_password(): return redirect("/portal/dashboard") + + +@app.route("/portal/invoices/download-all") +def portal_download_all_invoices(): + import io + import zipfile + + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, invoice_number + FROM invoices + WHERE client_id = %s + ORDER BY id + """, (client["id"],)) + invoices = cursor.fetchall() + conn.close() + + memory_file = io.BytesIO() + + with zipfile.ZipFile(memory_file, "w", zipfile.ZIP_DEFLATED) as zf: + for inv in invoices: + response = invoice_pdf(inv["id"]) + response.direct_passthrough = False + pdf_bytes = response.get_data() + + filename = f"{inv.get('invoice_number') or ('invoice_' + str(inv['id']))}.pdf" + zf.writestr(filename, pdf_bytes) + + memory_file.seek(0) + + return send_file( + memory_file, + download_name="all_invoices.zip", + as_attachment=True, + mimetype="application/zip", + ) + @app.route("/portal/dashboard", methods=["GET"]) def portal_dashboard(): client = _portal_current_client() @@ -3476,6 +3689,1138 @@ def portal_logout(): return redirect("/portal") + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, portal_enabled, portal_access_code, portal_password_hash + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + + if not client: + conn.close() + return redirect("/clients") + + if not client.get("portal_access_code") and not client.get("portal_password_hash"): + new_code = generate_portal_access_code() + cursor2 = conn.cursor() + cursor2.execute(""" + UPDATE clients + SET portal_enabled = 1, + portal_access_code = %s, + portal_access_code_created_at = UTC_TIMESTAMP(), + portal_force_password_change = 1 + WHERE id = %s + """, (new_code, client_id)) + else: + cursor2 = conn.cursor() + cursor2.execute(""" + UPDATE clients + SET portal_enabled = 1 + WHERE id = %s + """, (client_id,)) + + conn.commit() + conn.close() + return redirect(f"/clients/edit/{client_id}") + +@app.route("/clients/portal/disable/", methods=["POST"]) +def client_portal_disable(client_id): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET portal_enabled = 0 + WHERE id = %s + """, (client_id,)) + conn.commit() + conn.close() + return redirect(f"/clients/edit/{client_id}") + +@app.route("/clients/portal/reset-code/", methods=["POST"]) +def client_portal_reset_code(client_id): + new_code = generate_portal_access_code() + + conn = get_db_connection() + cursor = conn.cursor() + 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 + WHERE id = %s + """, (new_code, client_id)) + conn.commit() + conn.close() + + return redirect(f"/clients/edit/{client_id}") + + +@app.route("/clients/portal/send-invite/", methods=["POST"]) +def client_portal_send_invite(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + company_name, + contact_name, + email, + portal_enabled, + portal_access_code, + portal_password_hash, + portal_password_set_at + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + + if not client: + conn.close() + return redirect("/clients") + + if not client.get("email"): + conn.close() + return redirect(f"/clients/edit/{client_id}?portal_email_status=missing_email") + + access_code = client.get("portal_access_code") + + # If no active one-time code exists, generate a fresh one and require password setup again. + if not access_code: + access_code = generate_portal_access_code() + 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 + WHERE id = %s + """, (access_code, client_id)) + conn.commit() + + cursor.execute(""" + SELECT + id, + company_name, + contact_name, + email, + portal_enabled, + portal_access_code, + portal_password_hash, + portal_password_set_at + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + + elif not client.get("portal_enabled"): + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE clients + SET portal_enabled = 1 + WHERE id = %s + """, (client_id,)) + conn.commit() + + conn.close() + + contact_name = client.get("contact_name") or client.get("company_name") or "Client" + portal_email = client.get("email") or "" + portal_url = "https://portal.outsidethebox.top" + support_email = "support@outsidethebox.top" + + subject = "Your OutsideTheBox Client Portal Access" + body = f"""Hello {contact_name}, + +Your OutsideTheBox client portal access is now ready. + +Portal URL: +{portal_url} + +Login email: +{portal_email} + +Single-use access code: +{client.get("portal_access_code")} + +Important: +- This access code is single-use. +- 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 will use your email address and password. + +If you have any trouble signing in, contact support: +{support_email} + +Regards, +OutsideTheBox +""" + + try: + send_configured_email( + to_email=portal_email, + subject=subject, + body=body, + attachments=None, + email_type="portal_invite", + invoice_id=None + ) + return redirect(f"/clients/edit/{client_id}?portal_email_status=sent") + except Exception: + return redirect(f"/clients/edit/{client_id}?portal_email_status=error") + + +@app.route("/clients/portal/send-password-reset/", methods=["POST"]) +def client_portal_send_password_reset(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + company_name, + contact_name, + email, + portal_enabled + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + + if not client: + conn.close() + return redirect("/clients") + + if not client.get("email"): + conn.close() + return redirect(f"/clients/edit/{client_id}?portal_reset_status=missing_email") + + new_code = generate_portal_access_code() + + 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 + WHERE id = %s + """, (new_code, client_id)) + conn.commit() + conn.close() + + contact_name = client.get("contact_name") or client.get("company_name") or "Client" + portal_email = client.get("email") or "" + portal_url = "https://portal.outsidethebox.top" + support_email = "support@outsidethebox.top" + + subject = "Your OutsideTheBox Portal Password Reset" + body = f"""Hello {contact_name}, + +A password reset has been issued for your OutsideTheBox client portal access. + +Portal URL: +{portal_url} + +Login email: +{portal_email} + +New single-use access code: +{new_code} + +Important: +- This access code is single-use. +- It replaces your previous portal password. +- After you sign in, you will be asked to create a new password. +- Once your new password is created, this access code is cleared and future logins will use your email address and password. + +If you did not expect this reset, contact support immediately: +{support_email} + +Regards, +OutsideTheBox +""" + + try: + send_configured_email( + to_email=portal_email, + subject=subject, + body=body, + attachments=None, + email_type="portal_password_reset", + invoice_id=None + ) + return redirect(f"/clients/edit/{client_id}?portal_reset_status=sent") + except Exception: + return redirect(f"/clients/edit/{client_id}?portal_reset_status=error") + +@app.route("/portal/forgot-password", methods=["GET", "POST"]) +def portal_forgot_password(): + if request.method == "GET": + return render_template("portal_forgot_password.html", error=None, message=None, form_email="") + + email = (request.form.get("email") or "").strip().lower() + + if not email: + return render_template( + "portal_forgot_password.html", + error="Email address is required.", + message=None, + form_email="" + ) + + 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,)) + client = cursor.fetchone() + + if client: + new_code = generate_portal_access_code() + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE clients + SET 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, + portal_enabled = 1 + WHERE id = %s + """, (new_code, client["id"])) + conn.commit() + + contact_name = client.get("contact_name") or client.get("company_name") or "Client" + portal_url = "https://portal.outsidethebox.top" + support_email = "support@outsidethebox.top" + + subject = "Your OutsideTheBox Portal Password Reset" + + body = f"""Hello {contact_name}, + +A password reset was requested for your OutsideTheBox client portal. + +Portal URL: +{portal_url} + +Login email: +{client.get("email")} + +Single-use access code: +{new_code} + +Important: +- This access code is single-use. +- It replaces your previous portal password. +- After you sign in, you will be asked to create a new password. +- Once your new password is created, this access code is cleared and future logins will use your email address and password. + +If you did not request this reset, contact support immediately: +{support_email} + +Regards, +OutsideTheBox +""" + + try: + send_configured_email( + to_email=client.get("email"), + subject=subject, + body=body, + attachments=None, + email_type="portal_forgot_password", + invoice_id=None + ) + except Exception: + pass + + conn.close() + + return render_template( + "portal_forgot_password.html", + error=None, + message="If that email exists in our system, a reset message has been sent.", + form_email=email + ) + + + + +@app.route("/portal/invoice//pay-square", methods=["GET"]) +def portal_invoice_pay_square(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + i.*, + c.email AS client_email, + c.company_name, + c.contact_name + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.id = %s AND i.client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + conn.close() + + if not invoice: + return redirect("/portal/dashboard") + + status = (invoice.get("status") or "").lower() + if status == "paid": + return redirect(f"/portal/invoice/{invoice_id}") + + square_url = create_square_payment_link_for_invoice(invoice, invoice.get("client_email") or "") + return redirect(square_url) + +@app.route("/invoices/pay-square/", methods=["GET"]) +def admin_invoice_pay_square(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + i.*, + c.email AS client_email, + c.company_name, + c.contact_name + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = cursor.fetchone() + conn.close() + + if not invoice: + return "Invoice not found", 404 + + status = (invoice.get("status") or "").lower() + if status == "paid": + return redirect(f"/invoices/view/{invoice_id}") + + square_url = create_square_payment_link_for_invoice(invoice, invoice.get("client_email") or "") + return redirect(square_url) + + + +def auto_apply_square_payment(parsed_event): + try: + data_obj = (((parsed_event.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + + payment_id = payment.get("id") or "" + payment_status = (payment.get("status") or "").upper() + note = (payment.get("note") or "").strip() + buyer_email = (payment.get("buyer_email_address") or "").strip() + amount_money = (payment.get("amount_money") or {}).get("amount") + currency = (payment.get("amount_money") or {}).get("currency") or "CAD" + + if not payment_id or payment_status != "COMPLETED": + return {"processed": False, "reason": "not_completed_or_missing_id"} + + m = re.search(r'Invoice\s+([A-Za-z0-9\-]+)', note, re.IGNORECASE) + if not m: + return {"processed": False, "reason": "invoice_note_not_found", "note": note} + + invoice_number = m.group(1).strip() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Deduplicate by Square payment ID + cursor.execute(""" + SELECT id + FROM payments + WHERE txid = %s + LIMIT 1 + """, (payment_id,)) + existing = cursor.fetchone() + if existing: + conn.close() + return {"processed": False, "reason": "duplicate_payment_id", "payment_id": payment_id} + + cursor.execute(""" + SELECT + i.id, + i.client_id, + i.invoice_number, + i.currency_code, + i.total_amount, + i.amount_paid, + i.status, + c.company_name, + c.contact_name, + c.email + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.invoice_number = %s + LIMIT 1 + """, (invoice_number,)) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return {"processed": False, "reason": "invoice_not_found", "invoice_number": invoice_number} + + payment_amount = to_decimal(amount_money) / to_decimal("100") + + 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, %s, UTC_TIMESTAMP(), %s) + """, ( + invoice["id"], + invoice["client_id"], + "square", + currency, + payment_amount, + payment_amount if currency == "CAD" else payment_amount, + invoice_number, + buyer_email or "Square Customer", + payment_id, + "", + "confirmed", + f"Auto-recorded from Square webhook. Note: {note or ''}".strip() + )) + conn.commit() + conn.close() + + recalc_invoice_totals(invoice["id"]) + + try: + notify_conn = get_db_connection() + notify_cursor = notify_conn.cursor(dictionary=True) + notify_cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.currency_code, + c.company_name, + c.contact_name, + c.email + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.id = %s + LIMIT 1 + """, (invoice["id"],)) + invoice_email_row = notify_cursor.fetchone() + notify_conn.close() + + if invoice_email_row and invoice_email_row.get("email"): + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + subject = f"Payment Received for Invoice {invoice_email_row.get('invoice_number')}" + body = f"""Hello {client_name}, + +We have received your payment for invoice {invoice_email_row.get('invoice_number')}. + +Amount Received: +{payment_amount_display} + +Invoice Total: +{invoice_total_display} + +Current Invoice Status: +{invoice_email_row.get('status')} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=None, + email_type="payment_received", + invoice_id=invoice["id"] + ) + except Exception: + pass + + return { + "processed": True, + "invoice_number": invoice_number, + "payment_id": payment_id, + "amount": str(payment_amount), + "currency": currency, + } + + except Exception as e: + return {"processed": False, "reason": "exception", "error": str(e)} + + + + +@app.route("/accountbook/export.csv") +def accountbook_export_csv(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for pay in payments: + received = pay.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(pay.get("payment_method")) + amount = to_decimal(pay.get("cad_value_at_payment") or pay.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + output = StringIO() + writer = csv.writer(output) + writer.writerow(["Period", "Category", "Total CAD"]) + + for period_key in ("today", "month", "ytd"): + period = periods[period_key] + for cat_key, cat_label in categories: + writer.writerow([period["label"], cat_label, f"{period['totals'][cat_key]:.2f}"]) + writer.writerow([period["label"], "Grand Total", f"{period['grand']:.2f}"]) + + response = make_response(output.getvalue()) + response.headers["Content-Type"] = "text/csv; charset=utf-8" + response.headers["Content-Disposition"] = "attachment; filename=accountbook_summary.csv" + return response + + +@app.route("/accountbook") +def accountbook(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

+
{block['grand']:.2f}
+ + + {''.join(lines)} + +
+
+ """) + + html = f""" + + + + + Accountbook - OTB Billing + + + + + +
+
+
+

Accountbook

+

Confirmed payment totals by period and payment type.

+
+ +
+ +
+ {''.join(period_cards)} +
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/reconciliation") +def square_reconciliation(): + log_path = Path(SQUARE_WEBHOOK_LOG) + events = [] + + if log_path.exists(): + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + for line in reversed(lines[-400:]): + try: + row = json.loads(line) + events.append(row) + except Exception: + continue + + summary_cards = { + "processed_true": 0, + "duplicates": 0, + "failures": 0, + "sig_invalid": 0, + } + + for row in events[:150]: + if row.get("signature_valid") is False: + summary_cards["sig_invalid"] += 1 + auto_apply_result = row.get("auto_apply_result") + if isinstance(auto_apply_result, dict): + if auto_apply_result.get("processed") is True: + summary_cards["processed_true"] += 1 + elif auto_apply_result.get("reason") == "duplicate_payment_id": + summary_cards["duplicates"] += 1 + else: + summary_cards["failures"] += 1 + + summary_html = f""" + + """ + + filter_mode = (request.args.get("filter") or "").strip().lower() + + filtered_events = [] + for row in events[:150]: + auto_apply_result = row.get("auto_apply_result") + sig_valid = row.get("signature_valid") + + include = True + if filter_mode == "processed": + include = isinstance(auto_apply_result, dict) and auto_apply_result.get("processed") is True + elif filter_mode == "duplicates": + include = isinstance(auto_apply_result, dict) and auto_apply_result.get("reason") == "duplicate_payment_id" + elif filter_mode == "failures": + include = isinstance(auto_apply_result, dict) and auto_apply_result.get("processed") is False and auto_apply_result.get("reason") != "duplicate_payment_id" + elif filter_mode == "invalid": + include = (sig_valid is False) + + if include: + filtered_events.append(row) + + rows_html = [] + for row in filtered_events: + logged_at = row.get("logged_at_utc", "") + event_type = row.get("event_type", row.get("source", "")) + payment_id = row.get("payment_id", "") + note = row.get("note", "") + amount_money = row.get("amount_money", "") + signature_valid = row.get("signature_valid", "") + auto_apply_result = row.get("auto_apply_result") + + if isinstance(auto_apply_result, dict): + if auto_apply_result.get("processed") is True: + result_text = f"processed: true / invoice {auto_apply_result.get('invoice_number','')}" + result_class = "ok" + else: + result_text = f"processed: false / {auto_apply_result.get('reason','')}" + if auto_apply_result.get("error"): + result_text += f" / {auto_apply_result.get('error')}" + result_class = "warn" + else: + result_text = "" + result_class = "" + + signature_text = "true" if signature_valid is True else ("false" if signature_valid is False else "") + + rows_html.append(f""" + + {logged_at} + {event_type} + {payment_id} + {amount_money} + {note} + {signature_text} + {result_text} + + """) + + html = f""" + + + + + Square Reconciliation - OTB Billing + + + + + +
+
+
+

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + register_health_routes(app) if __name__ == "__main__": app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) diff --git a/backend/app.py.deduped_candidate b/backend/app.py.deduped_candidate new file mode 100644 index 0000000..d4e8660 --- /dev/null +++ b/backend/app.py.deduped_candidate @@ -0,0 +1,3312 @@ +import os +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import zipfile +import smtplib +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +text_for_pdf_routes = """import os +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import zipfile +import smtplib +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +text_for_pdf_routes = "" + + +def load_version(): + try: + with open(BASE_DIR / "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}" + + +def ensure_subscriptions_table(): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS subscriptions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + client_id INT UNSIGNED NOT NULL, + service_id INT UNSIGNED NULL, + subscription_name VARCHAR(255) NOT NULL, + billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly', + price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, + currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD', + start_date DATE NOT NULL, + next_invoice_date DATE NOT NULL, + status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_subscriptions_client_id (client_id), + KEY idx_subscriptions_service_id (service_id), + KEY idx_subscriptions_status (status), + KEY idx_subscriptions_next_invoice_date (next_invoice_date) + ) + """) + conn.commit() + conn.close() + + +def get_next_subscription_date(current_date, billing_interval): + if isinstance(current_date, str): + current_date = datetime.strptime(current_date, "%Y-%m-%d").date() + + if billing_interval == "yearly": + return current_date + relativedelta(years=1) + if billing_interval == "quarterly": + return current_date + relativedelta(months=3) + return current_date + relativedelta(months=1) + + +def generate_due_subscription_invoices(run_date=None): + ensure_subscriptions_table() + + today = run_date or date.today() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + WHERE s.status = 'active' + AND s.next_invoice_date <= %s + ORDER BY s.next_invoice_date ASC, s.id ASC + """, (today,)) + due_subscriptions = cursor.fetchall() + + created_count = 0 + created_invoice_numbers = [] + + for sub in due_subscriptions: + invoice_number = generate_invoice_number() + due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time()) + + note_parts = [f"Recurring subscription: {sub['subscription_name']}"] + if sub.get("service_code"): + note_parts.append(f"Service: {sub['service_code']}") + if sub.get("service_name"): + note_parts.append(f"({sub['service_name']})") + if sub.get("notes"): + note_parts.append(f"Notes: {sub['notes']}") + + note_text = " ".join(note_parts) + + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO invoices + ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + subtotal_amount, + tax_amount, + issued_at, + due_at, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s) + """, ( + sub["client_id"], + sub["service_id"], + invoice_number, + sub["currency_code"], + str(sub["price"]), + str(sub["price"]), + due_dt, + note_text, + )) + + next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"]) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE subscriptions + SET next_invoice_date = %s + WHERE id = %s + """, (next_date, sub["id"])) + + created_count += 1 + created_invoice_numbers.append(invoice_number) + + conn.commit() + conn.close() + + return { + "created_count": created_count, + "invoice_numbers": created_invoice_numbers, + "run_date": str(today), + } + + +APP_SETTINGS_DEFAULTS = { + "business_name": "OTB Billing", + "business_tagline": "By a contractor, for contractors", + "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 build_accounting_package_bytes(): + import json + import zipfile + from io import BytesIO + + report = get_revenue_report_data() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.created_at, + c.company_name, + c.contact_name + FROM invoices i + JOIN clients c ON i.client_id = c.id + ORDER BY i.created_at DESC + """) + invoices = cursor.fetchall() + + conn.close() + + payload = { + "report": report, + "invoices": invoices + } + + json_bytes = json.dumps(payload, indent=2, default=str).encode() + + zip_buffer = BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("revenue_report.json", json.dumps(report, indent=2)) + z.writestr("invoices.json", json.dumps(invoices, indent=2, default=str)) + + zip_buffer.seek(0) + + filename = f"accounting_package_{report.get('period_label','report')}.zip" + + return zip_buffer.read(), filename + + + +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("/subscriptions") +def subscriptions(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + ORDER BY s.id DESC + """) + subscriptions = cursor.fetchall() + conn.close() + + return render_template("subscriptions/list.html", subscriptions=subscriptions) + + +@app.route("/subscriptions/new", methods=["GET", "POST"]) +def new_subscription(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_id = request.form.get("service_id", "").strip() + subscription_name = request.form.get("subscription_name", "").strip() + billing_interval = request.form.get("billing_interval", "").strip() + price = request.form.get("price", "").strip() + currency_code = request.form.get("currency_code", "").strip() + start_date_value = request.form.get("start_date", "").strip() + next_invoice_date = request.form.get("next_invoice_date", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not subscription_name: + errors.append("Subscription name is required.") + if billing_interval not in {"monthly", "quarterly", "yearly"}: + errors.append("Billing interval is required.") + if not price: + errors.append("Price is required.") + if not currency_code: + errors.append("Currency is required.") + if not start_date_value: + errors.append("Start date is required.") + if not next_invoice_date: + errors.append("Next invoice date is required.") + if status not in {"active", "paused", "cancelled"}: + errors.append("Status is required.") + + if not errors: + try: + price_value = Decimal(str(price)) + if price_value <= Decimal("0"): + errors.append("Price must be greater than zero.") + except Exception: + errors.append("Price must be a valid number.") + + if errors: + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=errors, + form_data={ + "client_id": client_id, + "service_id": service_id, + "subscription_name": subscription_name, + "billing_interval": billing_interval, + "price": price, + "currency_code": currency_code, + "start_date": start_date_value, + "next_invoice_date": next_invoice_date, + "status": status, + "notes": notes, + }, + ) + + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO subscriptions + ( + client_id, + service_id, + subscription_name, + billing_interval, + price, + currency_code, + start_date, + next_invoice_date, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + client_id, + service_id or None, + subscription_name, + billing_interval, + str(price_value), + currency_code, + start_date_value, + next_invoice_date, + status, + notes or None, + )) + + conn.commit() + conn.close() + return redirect("/subscriptions") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + today_str = date.today().isoformat() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=[], + form_data={ + "billing_interval": "monthly", + "currency_code": "CAD", + "start_date": today_str, + "next_invoice_date": today_str, + "status": "active", + }, + ) + + +@app.route("/subscriptions/run", methods=["POST"]) +def run_subscriptions_now(): + result = generate_due_subscription_invoices() + return redirect(f"/subscriptions?run_count={result['created_count']}") + + + +@app.route("/reports/aging") +def report_aging(): + refresh_overdue_invoices() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + c.id AS client_id, + c.client_code, + c.company_name, + i.invoice_number, + i.due_at, + i.total_amount, + i.amount_paid, + (i.total_amount - i.amount_paid) AS remaining + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ORDER BY c.company_name, i.due_at + """) + rows = cursor.fetchall() + conn.close() + + today = datetime.utcnow().date() + grouped = {} + totals = { + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + for row in rows: + client_id = row["client_id"] + client_label = f"{row['client_code']} - {row['company_name']}" + + if client_id not in grouped: + grouped[client_id] = { + "client": client_label, + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + remaining = to_decimal(row["remaining"]) + + if row["due_at"]: + due_date = row["due_at"].date() + age_days = (today - due_date).days + else: + age_days = 0 + + if age_days <= 0: + bucket = "current" + elif age_days <= 30: + bucket = "d30" + elif age_days <= 60: + bucket = "d60" + elif age_days <= 90: + bucket = "d90" + else: + bucket = "d90p" + + grouped[client_id][bucket] += remaining + grouped[client_id]["total"] += remaining + + totals[bucket] += remaining + totals["total"] += remaining + + aging_rows = list(grouped.values()) + + return render_template( + "reports/aging.html", + aging_rows=aging_rows, + totals=totals + ) + + +@app.route("/") +def index(): + refresh_overdue_invoices() + + 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') + AND (total_amount - amount_paid) > 0 + """) + 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 = to_decimal(cursor.fetchone()["revenue_received"]) + + cursor.execute(""" + SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance + FROM invoices + WHERE status IN ('pending', 'partial', 'overdue') + AND (total_amount - amount_paid) > 0 + """) + outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"]) + + conn.close() + + app_settings = get_app_settings() + + return render_template( + "dashboard.html", + total_clients=total_clients, + active_services=active_services, + outstanding_invoices=outstanding_invoices, + outstanding_balance=outstanding_balance, + revenue_received=revenue_received, + app_settings=app_settings, + ) + +@app.route("/clients") +def clients(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + c.*, + COALESCE(( + SELECT SUM(i.total_amount - i.amount_paid) + FROM invoices i + WHERE i.client_id = c.id + AND i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ), 0) AS outstanding_balance + FROM clients c + ORDER BY c.company_name + """) + clients = cursor.fetchall() + + conn.close() + return render_template("clients/list.html", clients=clients) + +@app.route("/clients/new", methods=["GET", "POST"]) +def new_client(): + if request.method == "POST": + company_name = request.form["company_name"] + contact_name = request.form["contact_name"] + email = request.form["email"] + phone = request.form["phone"] + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT MAX(id) AS last_id FROM clients") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + + client_code = generate_client_code(company_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO clients + (client_code, company_name, contact_name, email, phone) + VALUES (%s, %s, %s, %s, %s) + """, + (client_code, company_name, contact_name, email, phone) + ) + conn.commit() + conn.close() + + return redirect("/clients") + + return render_template("clients/new.html") + +@app.route("/clients/edit/", methods=["GET", "POST"]) +def edit_client(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + company_name = request.form.get("company_name", "").strip() + contact_name = request.form.get("contact_name", "").strip() + email = request.form.get("email", "").strip() + phone = request.form.get("phone", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not company_name: + errors.append("Company name is required.") + if not status: + errors.append("Status is required.") + + if errors: + cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) + client = cursor.fetchone() + 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 = str(BASE_DIR) + 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() + + cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) + service_row = cursor.fetchone() + service_name = (service_row or {}).get("service_name") or "Service" + + line_description = service_name + if notes: + line_description = f"{service_name} - {notes}" + + 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 + )) + + invoice_id = insert_cursor.lastrowid + + insert_cursor.execute(""" + INSERT INTO invoice_items + ( + invoice_id, + line_number, + item_type, + description, + quantity, + unit_amount, + line_total, + currency_code, + service_id + ) + VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) + """, ( + invoice_id, + line_description, + total_amount, + total_amount, + currency_code, + service_id + )) + + 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 = str(BASE_DIR) + 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) + + cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) + service_row = cursor.fetchone() + service_name = (service_row or {}).get("service_name") or "Service" + + line_description = service_name + if notes: + line_description = f"{service_name} - {notes}" + + 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 + )) + + update_cursor.execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_id,)) + update_cursor.execute(""" + INSERT INTO invoice_items + ( + invoice_id, + line_number, + item_type, + description, + quantity, + unit_amount, + line_total, + currency_code, + service_id + ) + VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) + """, ( + invoice_id, + line_description, + total_amount, + total_amount, + currency_code, + service_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=[]) + + +def _portal_current_client(): + client_id = session.get("portal_client_id") + if not client_id: + return None + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + conn.close() + return client + +@app.route("/portal", methods=["GET"]) +def portal_index(): + if session.get("portal_client_id"): + return redirect("/portal/dashboard") + return render_template("portal_login.html") + +@app.route("/portal/login", methods=["POST"]) +def portal_login(): + email = (request.form.get("email") or "").strip().lower() + credential = (request.form.get("credential") or "").strip() + + if not email or not credential: + return render_template("portal_login.html", portal_message="Email and access code or password are required.", portal_email=email) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, + portal_password_hash, portal_force_password_change + FROM clients + WHERE LOWER(email) = %s + LIMIT 1 + """, (email,)) + client = cursor.fetchone() + + if not client or not client.get("portal_enabled"): + conn.close() + return render_template("portal_login.html", portal_message="Portal access is not enabled for that email address.", portal_email=email) + + password_hash = client.get("portal_password_hash") + access_code = client.get("portal_access_code") or "" + + ok = False + first_login = False + + if password_hash: + ok = check_password_hash(password_hash, credential) + else: + ok = (credential == access_code) + first_login = ok + + if not ok and access_code and credential == access_code: + ok = True + first_login = True + + if not ok: + conn.close() + return render_template("portal_login.html", portal_message="Invalid credentials.", portal_email=email) + + session["portal_client_id"] = client["id"] + session["portal_email"] = client["email"] + + cursor.execute(""" + UPDATE clients + SET portal_last_login_at = UTC_TIMESTAMP() + WHERE id = %s + """, (client["id"],)) + conn.commit() + conn.close() + + if first_login or client.get("portal_force_password_change"): + return redirect("/portal/set-password") + + return redirect("/portal/dashboard") + +@app.route("/portal/set-password", methods=["GET", "POST"]) +def portal_set_password(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + client_name = client.get("company_name") or client.get("contact_name") or client.get("email") + + if request.method == "GET": + return render_template("portal_set_password.html", client_name=client_name) + + password = (request.form.get("password") or "") + password2 = (request.form.get("password2") or "") + + if len(password) < 10: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Password must be at least 10 characters long.") + if password != password2: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Passwords do not match.") + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET portal_password_hash = %s, + portal_password_set_at = UTC_TIMESTAMP(), + portal_force_password_change = 0, + portal_access_code = NULL + WHERE id = %s + """, (generate_password_hash(password), client["id"])) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + +@app.route("/portal/dashboard", methods=["GET"]) +def portal_dashboard(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE client_id = %s + ORDER BY created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + for row in invoices: + outstanding = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + row["outstanding"] = _fmt_money(outstanding) + row["total_amount"] = _fmt_money(row.get("total_amount")) + row["amount_paid"] = _fmt_money(row.get("amount_paid")) + row["created_at"] = fmt_local(row.get("created_at")) + + total_outstanding = sum((to_decimal(r["outstanding"]) for r in invoices), to_decimal("0")) + total_paid = sum((to_decimal(r["amount_paid"]) for r in invoices), to_decimal("0")) + + conn.close() + + return render_template( + "portal_dashboard.html", + client=client, + invoices=invoices, + invoice_count=len(invoices), + total_outstanding=f"{total_outstanding:.2f}", + total_paid=f"{total_paid:.2f}", + ) + + +@app.route("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + cursor.execute(""" + SELECT description, quantity, unit_amount AS unit_price, line_total + FROM invoice_items + WHERE invoice_id = %s + ORDER BY id ASC + """, (invoice_id,)) + items = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + invoice["created_at"] = fmt_local(invoice.get("created_at")) + + for item in items: + item["quantity"] = _fmt_money(item.get("quantity")) + item["unit_price"] = _fmt_money(item.get("unit_price")) + item["line_total"] = _fmt_money(item.get("line_total")) + + pdf_url = None + for candidate in ( + f"/invoices/{invoice_id}/pdf", + f"/invoice/{invoice_id}/pdf", + f"/invoices/{invoice_id}/print", + ): + if candidate in text_for_pdf_routes: + pdf_url = candidate + break + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) diff --git a/backend/app_cleanup_test.py b/backend/app_cleanup_test.py new file mode 100644 index 0000000..5ff876a --- /dev/null +++ b/backend/app_cleanup_test.py @@ -0,0 +1,6503 @@ +import os +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import zipfile +import smtplib +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +text_for_pdf_routes = """import os +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import zipfile +import smtplib +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +text_for_pdf_routes = "" + + +def load_version(): + try: + with open(BASE_DIR / "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}" + + +def ensure_subscriptions_table(): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(\"\"\" + CREATE TABLE IF NOT EXISTS subscriptions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + client_id INT UNSIGNED NOT NULL, + service_id INT UNSIGNED NULL, + subscription_name VARCHAR(255) NOT NULL, + billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly', + price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, + currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD', + start_date DATE NOT NULL, + next_invoice_date DATE NOT NULL, + status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_subscriptions_client_id (client_id), + KEY idx_subscriptions_service_id (service_id), + KEY idx_subscriptions_status (status), + KEY idx_subscriptions_next_invoice_date (next_invoice_date) + ) + \"\"\") + conn.commit() + conn.close() + + +def get_next_subscription_date(current_date, billing_interval): + if isinstance(current_date, str): + current_date = datetime.strptime(current_date, "%Y-%m-%d").date() + + if billing_interval == "yearly": + return current_date + relativedelta(years=1) + if billing_interval == "quarterly": + return current_date + relativedelta(months=3) + return current_date + relativedelta(months=1) + + +def generate_due_subscription_invoices(run_date=None): + ensure_subscriptions_table() + + today = run_date or date.today() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(\"\"\" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + WHERE s.status = 'active' + AND s.next_invoice_date <= %s + ORDER BY s.next_invoice_date ASC, s.id ASC + \"\"\", (today,)) + due_subscriptions = cursor.fetchall() + + created_count = 0 + created_invoice_numbers = [] + + for sub in due_subscriptions: + invoice_number = generate_invoice_number() + due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time()) + + note_parts = [f"Recurring subscription: {sub['subscription_name']}"] + if sub.get("service_code"): + note_parts.append(f"Service: {sub['service_code']}") + if sub.get("service_name"): + note_parts.append(f"({sub['service_name']})") + if sub.get("notes"): + note_parts.append(f"Notes: {sub['notes']}") + + note_text = " ".join(note_parts) + + insert_cursor = conn.cursor() + insert_cursor.execute(\"\"\" + INSERT INTO invoices + ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + subtotal_amount, + tax_amount, + issued_at, + due_at, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s) + \"\"\", ( + sub["client_id"], + sub["service_id"], + invoice_number, + sub["currency_code"], + str(sub["price"]), + str(sub["price"]), + due_dt, + note_text, + )) + + next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"]) + + update_cursor = conn.cursor() + update_cursor.execute(\"\"\" + UPDATE subscriptions + SET next_invoice_date = %s + WHERE id = %s + \"\"\", (next_date, sub["id"])) + + created_count += 1 + created_invoice_numbers.append(invoice_number) + + conn.commit() + conn.close() + + return { + "created_count": created_count, + "invoice_numbers": created_invoice_numbers, + "run_date": str(today), + } + + +APP_SETTINGS_DEFAULTS = { + "business_name": "OTB Billing", + "business_tagline": "By a contractor, for contractors", + "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 build_accounting_package_bytes(): + import json + import zipfile + from io import BytesIO + + report = get_revenue_report_data() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(\"\"\" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.created_at, + c.company_name, + c.contact_name + FROM invoices i + JOIN clients c ON i.client_id = c.id + ORDER BY i.created_at DESC + \"\"\") + invoices = cursor.fetchall() + + conn.close() + + payload = { + "report": report, + "invoices": invoices + } + + json_bytes = json.dumps(payload, indent=2, default=str).encode() + + zip_buffer = BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("revenue_report.json", json.dumps(report, indent=2)) + z.writestr("invoices.json", json.dumps(invoices, indent=2, default=str)) + + zip_buffer.seek(0) + + filename = f"accounting_package_{report.get('period_label','report')}.zip" + + return zip_buffer.read(), filename + + + +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 ''}, + +" + f"Please find attached invoice {invoice['invoice_number']}. +" + f"Total: {to_decimal(invoice.get('total_amount')):.2f} {invoice.get('currency_code', 'CAD')} +" + f"Remaining: {remaining:.2f} {invoice.get('currency_code', 'CAD')} +" + f"Due: {fmt_local(invoice.get('due_at'))} + +" + f"Thank you, +" + 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', '')}. + +" + f"Frequency: {report.get('frequency', '')} +" + f"Collected CAD: {report.get('collected_cad', '')} +" + f"Invoices Issued: {report.get('invoice_count', '')} +" + ) + + 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("/subscriptions") +def subscriptions(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(\"\"\" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + ORDER BY s.id DESC + \"\"\") + subscriptions = cursor.fetchall() + conn.close() + + return render_template("subscriptions/list.html", subscriptions=subscriptions) + + +@app.route("/subscriptions/new", methods=["GET", "POST"]) +def new_subscription(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_id = request.form.get("service_id", "").strip() + subscription_name = request.form.get("subscription_name", "").strip() + billing_interval = request.form.get("billing_interval", "").strip() + price = request.form.get("price", "").strip() + currency_code = request.form.get("currency_code", "").strip() + start_date_value = request.form.get("start_date", "").strip() + next_invoice_date = request.form.get("next_invoice_date", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not subscription_name: + errors.append("Subscription name is required.") + if billing_interval not in {"monthly", "quarterly", "yearly"}: + errors.append("Billing interval is required.") + if not price: + errors.append("Price is required.") + if not currency_code: + errors.append("Currency is required.") + if not start_date_value: + errors.append("Start date is required.") + if not next_invoice_date: + errors.append("Next invoice date is required.") + if status not in {"active", "paused", "cancelled"}: + errors.append("Status is required.") + + if not errors: + try: + price_value = Decimal(str(price)) + if price_value <= Decimal("0"): + errors.append("Price must be greater than zero.") + except Exception: + errors.append("Price must be a valid number.") + + if errors: + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=errors, + form_data={ + "client_id": client_id, + "service_id": service_id, + "subscription_name": subscription_name, + "billing_interval": billing_interval, + "price": price, + "currency_code": currency_code, + "start_date": start_date_value, + "next_invoice_date": next_invoice_date, + "status": status, + "notes": notes, + }, + ) + + insert_cursor = conn.cursor() + insert_cursor.execute(\"\"\" + INSERT INTO subscriptions + ( + client_id, + service_id, + subscription_name, + billing_interval, + price, + currency_code, + start_date, + next_invoice_date, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + \"\"\", ( + client_id, + service_id or None, + subscription_name, + billing_interval, + str(price_value), + currency_code, + start_date_value, + next_invoice_date, + status, + notes or None, + )) + + conn.commit() + conn.close() + return redirect("/subscriptions") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + today_str = date.today().isoformat() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=[], + form_data={ + "billing_interval": "monthly", + "currency_code": "CAD", + "start_date": today_str, + "next_invoice_date": today_str, + "status": "active", + }, + ) + + +@app.route("/subscriptions/run", methods=["POST"]) +def run_subscriptions_now(): + result = generate_due_subscription_invoices() + return redirect(f"/subscriptions?run_count={result['created_count']}") + + + +@app.route("/reports/aging") +def report_aging(): + refresh_overdue_invoices() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(\"\"\" + SELECT + c.id AS client_id, + c.client_code, + c.company_name, + i.invoice_number, + i.due_at, + i.total_amount, + i.amount_paid, + (i.total_amount - i.amount_paid) AS remaining + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ORDER BY c.company_name, i.due_at + \"\"\") + rows = cursor.fetchall() + conn.close() + + today = datetime.utcnow().date() + grouped = {} + totals = { + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + for row in rows: + client_id = row["client_id"] + client_label = f"{row['client_code']} - {row['company_name']}" + + if client_id not in grouped: + grouped[client_id] = { + "client": client_label, + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + remaining = to_decimal(row["remaining"]) + + if row["due_at"]: + due_date = row["due_at"].date() + age_days = (today - due_date).days + else: + age_days = 0 + + if age_days <= 0: + bucket = "current" + elif age_days <= 30: + bucket = "d30" + elif age_days <= 60: + bucket = "d60" + elif age_days <= 90: + bucket = "d90" + else: + bucket = "d90p" + + grouped[client_id][bucket] += remaining + grouped[client_id]["total"] += remaining + + totals[bucket] += remaining + totals["total"] += remaining + + aging_rows = list(grouped.values()) + + return render_template( + "reports/aging.html", + aging_rows=aging_rows, + totals=totals + ) + + +@app.route("/") +def index(): + refresh_overdue_invoices() + + 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') + AND (total_amount - amount_paid) > 0 + \"\"\") + 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 = to_decimal(cursor.fetchone()["revenue_received"]) + + cursor.execute(\"\"\" + SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance + FROM invoices + WHERE status IN ('pending', 'partial', 'overdue') + AND (total_amount - amount_paid) > 0 + \"\"\") + outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"]) + + conn.close() + + app_settings = get_app_settings() + + return render_template( + "dashboard.html", + total_clients=total_clients, + active_services=active_services, + outstanding_invoices=outstanding_invoices, + outstanding_balance=outstanding_balance, + revenue_received=revenue_received, + app_settings=app_settings, + ) + +@app.route("/clients") +def clients(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(\"\"\" + SELECT + c.*, + COALESCE(( + SELECT SUM(i.total_amount - i.amount_paid) + FROM invoices i + WHERE i.client_id = c.id + AND i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ), 0) AS outstanding_balance + FROM clients c + ORDER BY c.company_name + \"\"\") + clients = cursor.fetchall() + + conn.close() + return render_template("clients/list.html", clients=clients) + +@app.route("/clients/new", methods=["GET", "POST"]) +def new_client(): + if request.method == "POST": + company_name = request.form["company_name"] + contact_name = request.form["contact_name"] + email = request.form["email"] + phone = request.form["phone"] + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT MAX(id) AS last_id FROM clients") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + + client_code = generate_client_code(company_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + \"\"\" + INSERT INTO clients + (client_code, company_name, contact_name, email, phone) + VALUES (%s, %s, %s, %s, %s) + \"\"\", + (client_code, company_name, contact_name, email, phone) + ) + conn.commit() + conn.close() + + return redirect("/clients") + + return render_template("clients/new.html") + +@app.route("/clients/edit/", methods=["GET", "POST"]) +def edit_client(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + company_name = request.form.get("company_name", "").strip() + contact_name = request.form.get("contact_name", "").strip() + email = request.form.get("email", "").strip() + phone = request.form.get("phone", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not company_name: + errors.append("Company name is required.") + if not status: + errors.append("Status is required.") + + if errors: + cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) + client = cursor.fetchone() + 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 = str(BASE_DIR) + 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 = str(BASE_DIR) + 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=[]) + + +def _portal_current_client(): + client_id = session.get("portal_client_id") + if not client_id: + return None + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(\"\"\" + SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change + FROM clients + WHERE id = %s + LIMIT 1 + \"\"\", (client_id,)) + client = cursor.fetchone() + conn.close() + return client + +@app.route("/portal", methods=["GET"]) +def portal_index(): + if session.get("portal_client_id"): + return redirect("/portal/dashboard") + return render_template("portal_login.html") + +@app.route("/portal/login", methods=["POST"]) +def portal_login(): + email = (request.form.get("email") or "").strip().lower() + credential = (request.form.get("credential") or "").strip() + + if not email or not credential: + return render_template("portal_login.html", portal_message="Email and access code or password are required.", portal_email=email) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(\"\"\" + SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, + portal_password_hash, portal_force_password_change + FROM clients + WHERE LOWER(email) = %s + LIMIT 1 + \"\"\", (email,)) + client = cursor.fetchone() + + if not client or not client.get("portal_enabled"): + conn.close() + return render_template("portal_login.html", portal_message="Portal access is not enabled for that email address.", portal_email=email) + + password_hash = client.get("portal_password_hash") + access_code = client.get("portal_access_code") or "" + + ok = False + first_login = False + + if password_hash: + ok = check_password_hash(password_hash, credential) + else: + ok = (credential == access_code) + first_login = ok + + if not ok and access_code and credential == access_code: + ok = True + first_login = True + + if not ok: + conn.close() + return render_template("portal_login.html", portal_message="Invalid credentials.", portal_email=email) + + session["portal_client_id"] = client["id"] + session["portal_email"] = client["email"] + + cursor.execute(\"\"\" + UPDATE clients + SET portal_last_login_at = UTC_TIMESTAMP() + WHERE id = %s + \"\"\", (client["id"],)) + conn.commit() + conn.close() + + if first_login or client.get("portal_force_password_change"): + return redirect("/portal/set-password") + + return redirect("/portal/dashboard") + +@app.route("/portal/set-password", methods=["GET", "POST"]) +def portal_set_password(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + client_name = client.get("company_name") or client.get("contact_name") or client.get("email") + + if request.method == "GET": + return render_template("portal_set_password.html", client_name=client_name) + + password = (request.form.get("password") or "") + password2 = (request.form.get("password2") or "") + + if len(password) < 10: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Password must be at least 10 characters long.") + if password != password2: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Passwords do not match.") + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(\"\"\" + UPDATE clients + SET portal_password_hash = %s, + portal_password_set_at = UTC_TIMESTAMP(), + portal_force_password_change = 0, + portal_access_code = NULL + WHERE id = %s + \"\"\", (generate_password_hash(password), client["id"])) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + +@app.route("/portal/dashboard", methods=["GET"]) +def portal_dashboard(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(\"\"\" + SELECT id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE client_id = %s + ORDER BY created_at DESC + \"\"\", (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + for row in invoices: + outstanding = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + row["outstanding"] = _fmt_money(outstanding) + row["total_amount"] = _fmt_money(row.get("total_amount")) + row["amount_paid"] = _fmt_money(row.get("amount_paid")) + row["created_at"] = fmt_local(row.get("created_at")) + + total_outstanding = sum((to_decimal(r["outstanding"]) for r in invoices), to_decimal("0")) + total_paid = sum((to_decimal(r["amount_paid"]) for r in invoices), to_decimal("0")) + + conn.close() + + return render_template( + "portal_dashboard.html", + client=client, + invoices=invoices, + invoice_count=len(invoices), + total_outstanding=f"{total_outstanding:.2f}", + total_paid=f"{total_paid:.2f}", + ) + + +@app.route("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(\"\"\" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + \"\"\", (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + cursor.execute(\"\"\" + SELECT description, quantity, unit_amount AS unit_price, line_total + FROM invoice_items + WHERE invoice_id = %s + ORDER BY id ASC + \"\"\", (invoice_id,)) + items = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + invoice["created_at"] = fmt_local(invoice.get("created_at")) + + for item in items: + item["quantity"] = _fmt_money(item.get("quantity")) + item["unit_price"] = _fmt_money(item.get("unit_price")) + item["line_total"] = _fmt_money(item.get("line_total")) + + pdf_url = None + for candidate in ( + f"/invoices/{invoice_id}/pdf", + f"/invoice/{invoice_id}/pdf", + f"/invoices/{invoice_id}/print", + ): + if candidate in text_for_pdf_routes: + pdf_url = candidate + break + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) +""" + + +def load_version(): + try: + with open(BASE_DIR / "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}" + + +def ensure_subscriptions_table(): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS subscriptions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + client_id INT UNSIGNED NOT NULL, + service_id INT UNSIGNED NULL, + subscription_name VARCHAR(255) NOT NULL, + billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly', + price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, + currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD', + start_date DATE NOT NULL, + next_invoice_date DATE NOT NULL, + status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_subscriptions_client_id (client_id), + KEY idx_subscriptions_service_id (service_id), + KEY idx_subscriptions_status (status), + KEY idx_subscriptions_next_invoice_date (next_invoice_date) + ) + """) + conn.commit() + conn.close() + + +def get_next_subscription_date(current_date, billing_interval): + if isinstance(current_date, str): + current_date = datetime.strptime(current_date, "%Y-%m-%d").date() + + if billing_interval == "yearly": + return current_date + relativedelta(years=1) + if billing_interval == "quarterly": + return current_date + relativedelta(months=3) + return current_date + relativedelta(months=1) + + +def generate_due_subscription_invoices(run_date=None): + ensure_subscriptions_table() + + today = run_date or date.today() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + WHERE s.status = 'active' + AND s.next_invoice_date <= %s + ORDER BY s.next_invoice_date ASC, s.id ASC + """, (today,)) + due_subscriptions = cursor.fetchall() + + created_count = 0 + created_invoice_numbers = [] + + for sub in due_subscriptions: + invoice_number = generate_invoice_number() + due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time()) + + note_parts = [f"Recurring subscription: {sub['subscription_name']}"] + if sub.get("service_code"): + note_parts.append(f"Service: {sub['service_code']}") + if sub.get("service_name"): + note_parts.append(f"({sub['service_name']})") + if sub.get("notes"): + note_parts.append(f"Notes: {sub['notes']}") + + note_text = " ".join(note_parts) + + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO invoices + ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + subtotal_amount, + tax_amount, + issued_at, + due_at, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s) + """, ( + sub["client_id"], + sub["service_id"], + invoice_number, + sub["currency_code"], + str(sub["price"]), + str(sub["price"]), + due_dt, + note_text, + )) + + next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"]) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE subscriptions + SET next_invoice_date = %s + WHERE id = %s + """, (next_date, sub["id"])) + + created_count += 1 + created_invoice_numbers.append(invoice_number) + + conn.commit() + conn.close() + + return { + "created_count": created_count, + "invoice_numbers": created_invoice_numbers, + "run_date": str(today), + } + + +APP_SETTINGS_DEFAULTS = { + "business_name": "OTB Billing", + "business_tagline": "By a contractor, for contractors", + "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 build_accounting_package_bytes(): + import json + import zipfile + from io import BytesIO + + report = get_revenue_report_data() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.created_at, + c.company_name, + c.contact_name + FROM invoices i + JOIN clients c ON i.client_id = c.id + ORDER BY i.created_at DESC + """) + invoices = cursor.fetchall() + + conn.close() + + payload = { + "report": report, + "invoices": invoices + } + + json_bytes = json.dumps(payload, indent=2, default=str).encode() + + zip_buffer = BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("revenue_report.json", json.dumps(report, indent=2)) + z.writestr("invoices.json", json.dumps(invoices, indent=2, default=str)) + + zip_buffer.seek(0) + + filename = f"accounting_package_{report.get('period_label','report')}.zip" + + return zip_buffer.read(), filename + + + +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("/subscriptions") +def subscriptions(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + ORDER BY s.id DESC + """) + subscriptions = cursor.fetchall() + conn.close() + + return render_template("subscriptions/list.html", subscriptions=subscriptions) + + +@app.route("/subscriptions/new", methods=["GET", "POST"]) +def new_subscription(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_id = request.form.get("service_id", "").strip() + subscription_name = request.form.get("subscription_name", "").strip() + billing_interval = request.form.get("billing_interval", "").strip() + price = request.form.get("price", "").strip() + currency_code = request.form.get("currency_code", "").strip() + start_date_value = request.form.get("start_date", "").strip() + next_invoice_date = request.form.get("next_invoice_date", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not subscription_name: + errors.append("Subscription name is required.") + if billing_interval not in {"monthly", "quarterly", "yearly"}: + errors.append("Billing interval is required.") + if not price: + errors.append("Price is required.") + if not currency_code: + errors.append("Currency is required.") + if not start_date_value: + errors.append("Start date is required.") + if not next_invoice_date: + errors.append("Next invoice date is required.") + if status not in {"active", "paused", "cancelled"}: + errors.append("Status is required.") + + if not errors: + try: + price_value = Decimal(str(price)) + if price_value <= Decimal("0"): + errors.append("Price must be greater than zero.") + except Exception: + errors.append("Price must be a valid number.") + + if errors: + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=errors, + form_data={ + "client_id": client_id, + "service_id": service_id, + "subscription_name": subscription_name, + "billing_interval": billing_interval, + "price": price, + "currency_code": currency_code, + "start_date": start_date_value, + "next_invoice_date": next_invoice_date, + "status": status, + "notes": notes, + }, + ) + + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO subscriptions + ( + client_id, + service_id, + subscription_name, + billing_interval, + price, + currency_code, + start_date, + next_invoice_date, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + client_id, + service_id or None, + subscription_name, + billing_interval, + str(price_value), + currency_code, + start_date_value, + next_invoice_date, + status, + notes or None, + )) + + conn.commit() + conn.close() + return redirect("/subscriptions") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + today_str = date.today().isoformat() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=[], + form_data={ + "billing_interval": "monthly", + "currency_code": "CAD", + "start_date": today_str, + "next_invoice_date": today_str, + "status": "active", + }, + ) + + +@app.route("/subscriptions/run", methods=["POST"]) +def run_subscriptions_now(): + result = generate_due_subscription_invoices() + return redirect(f"/subscriptions?run_count={result['created_count']}") + + + +@app.route("/reports/aging") +def report_aging(): + refresh_overdue_invoices() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + c.id AS client_id, + c.client_code, + c.company_name, + i.invoice_number, + i.due_at, + i.total_amount, + i.amount_paid, + (i.total_amount - i.amount_paid) AS remaining + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ORDER BY c.company_name, i.due_at + """) + rows = cursor.fetchall() + conn.close() + + today = datetime.utcnow().date() + grouped = {} + totals = { + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + for row in rows: + client_id = row["client_id"] + client_label = f"{row['client_code']} - {row['company_name']}" + + if client_id not in grouped: + grouped[client_id] = { + "client": client_label, + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + remaining = to_decimal(row["remaining"]) + + if row["due_at"]: + due_date = row["due_at"].date() + age_days = (today - due_date).days + else: + age_days = 0 + + if age_days <= 0: + bucket = "current" + elif age_days <= 30: + bucket = "d30" + elif age_days <= 60: + bucket = "d60" + elif age_days <= 90: + bucket = "d90" + else: + bucket = "d90p" + + grouped[client_id][bucket] += remaining + grouped[client_id]["total"] += remaining + + totals[bucket] += remaining + totals["total"] += remaining + + aging_rows = list(grouped.values()) + + return render_template( + "reports/aging.html", + aging_rows=aging_rows, + totals=totals + ) + + +@app.route("/") +def index(): + refresh_overdue_invoices() + + 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') + AND (total_amount - amount_paid) > 0 + """) + 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 = to_decimal(cursor.fetchone()["revenue_received"]) + + cursor.execute(""" + SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance + FROM invoices + WHERE status IN ('pending', 'partial', 'overdue') + AND (total_amount - amount_paid) > 0 + """) + outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"]) + + conn.close() + + app_settings = get_app_settings() + + return render_template( + "dashboard.html", + total_clients=total_clients, + active_services=active_services, + outstanding_invoices=outstanding_invoices, + outstanding_balance=outstanding_balance, + revenue_received=revenue_received, + app_settings=app_settings, + ) + +@app.route("/clients") +def clients(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + c.*, + COALESCE(( + SELECT SUM(i.total_amount - i.amount_paid) + FROM invoices i + WHERE i.client_id = c.id + AND i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ), 0) AS outstanding_balance + FROM clients c + ORDER BY c.company_name + """) + clients = cursor.fetchall() + + conn.close() + return render_template("clients/list.html", clients=clients) + +@app.route("/clients/new", methods=["GET", "POST"]) +def new_client(): + if request.method == "POST": + company_name = request.form["company_name"] + contact_name = request.form["contact_name"] + email = request.form["email"] + phone = request.form["phone"] + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT MAX(id) AS last_id FROM clients") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + + client_code = generate_client_code(company_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO clients + (client_code, company_name, contact_name, email, phone) + VALUES (%s, %s, %s, %s, %s) + """, + (client_code, company_name, contact_name, email, phone) + ) + conn.commit() + conn.close() + + return redirect("/clients") + + return render_template("clients/new.html") + +@app.route("/clients/edit/", methods=["GET", "POST"]) +def edit_client(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + company_name = request.form.get("company_name", "").strip() + contact_name = request.form.get("contact_name", "").strip() + email = request.form.get("email", "").strip() + phone = request.form.get("phone", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not company_name: + errors.append("Company name is required.") + if not status: + errors.append("Status is required.") + + if errors: + cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) + client = cursor.fetchone() + 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 = str(BASE_DIR) + 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() + + cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) + service_row = cursor.fetchone() + service_name = (service_row or {}).get("service_name") or "Service" + + line_description = service_name + if notes: + line_description = f"{service_name} - {notes}" + + 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 + )) + + invoice_id = insert_cursor.lastrowid + + insert_cursor.execute(""" + INSERT INTO invoice_items + ( + invoice_id, + line_number, + item_type, + description, + quantity, + unit_amount, + line_total, + currency_code, + service_id + ) + VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) + """, ( + invoice_id, + line_description, + total_amount, + total_amount, + currency_code, + service_id + )) + + 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 = str(BASE_DIR) + 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) + + cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) + service_row = cursor.fetchone() + service_name = (service_row or {}).get("service_name") or "Service" + + line_description = service_name + if notes: + line_description = f"{service_name} - {notes}" + + 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 + )) + + update_cursor.execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_id,)) + update_cursor.execute(""" + INSERT INTO invoice_items + ( + invoice_id, + line_number, + item_type, + description, + quantity, + unit_amount, + line_total, + currency_code, + service_id + ) + VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) + """, ( + invoice_id, + line_description, + total_amount, + total_amount, + currency_code, + service_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=[]) + + +def _portal_current_client(): + client_id = session.get("portal_client_id") + if not client_id: + return None + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + conn.close() + return client + +@app.route("/portal", methods=["GET"]) +def portal_index(): + if session.get("portal_client_id"): + return redirect("/portal/dashboard") + return render_template("portal_login.html") + +@app.route("/portal/login", methods=["POST"]) +def portal_login(): + email = (request.form.get("email") or "").strip().lower() + credential = (request.form.get("credential") or "").strip() + + if not email or not credential: + return render_template("portal_login.html", portal_message="Email and access code or password are required.", portal_email=email) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, + portal_password_hash, portal_force_password_change + FROM clients + WHERE LOWER(email) = %s + LIMIT 1 + """, (email,)) + client = cursor.fetchone() + + if not client or not client.get("portal_enabled"): + conn.close() + return render_template("portal_login.html", portal_message="Portal access is not enabled for that email address.", portal_email=email) + + password_hash = client.get("portal_password_hash") + access_code = client.get("portal_access_code") or "" + + ok = False + first_login = False + + if password_hash: + ok = check_password_hash(password_hash, credential) + else: + ok = (credential == access_code) + first_login = ok + + if not ok and access_code and credential == access_code: + ok = True + first_login = True + + if not ok: + conn.close() + return render_template("portal_login.html", portal_message="Invalid credentials.", portal_email=email) + + session["portal_client_id"] = client["id"] + session["portal_email"] = client["email"] + + cursor.execute(""" + UPDATE clients + SET portal_last_login_at = UTC_TIMESTAMP() + WHERE id = %s + """, (client["id"],)) + conn.commit() + conn.close() + + if first_login or client.get("portal_force_password_change"): + return redirect("/portal/set-password") + + return redirect("/portal/dashboard") + +@app.route("/portal/set-password", methods=["GET", "POST"]) +def portal_set_password(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + client_name = client.get("company_name") or client.get("contact_name") or client.get("email") + + if request.method == "GET": + return render_template("portal_set_password.html", client_name=client_name) + + password = (request.form.get("password") or "") + password2 = (request.form.get("password2") or "") + + if len(password) < 10: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Password must be at least 10 characters long.") + if password != password2: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Passwords do not match.") + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET portal_password_hash = %s, + portal_password_set_at = UTC_TIMESTAMP(), + portal_force_password_change = 0, + portal_access_code = NULL + WHERE id = %s + """, (generate_password_hash(password), client["id"])) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + +@app.route("/portal/dashboard", methods=["GET"]) +def portal_dashboard(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE client_id = %s + ORDER BY created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + for row in invoices: + outstanding = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + row["outstanding"] = _fmt_money(outstanding) + row["total_amount"] = _fmt_money(row.get("total_amount")) + row["amount_paid"] = _fmt_money(row.get("amount_paid")) + row["created_at"] = fmt_local(row.get("created_at")) + + total_outstanding = sum((to_decimal(r["outstanding"]) for r in invoices), to_decimal("0")) + total_paid = sum((to_decimal(r["amount_paid"]) for r in invoices), to_decimal("0")) + + conn.close() + + return render_template( + "portal_dashboard.html", + client=client, + invoices=invoices, + invoice_count=len(invoices), + total_outstanding=f"{total_outstanding:.2f}", + total_paid=f"{total_paid:.2f}", + ) + + +@app.route("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + cursor.execute(""" + SELECT description, quantity, unit_amount AS unit_price, line_total + FROM invoice_items + WHERE invoice_id = %s + ORDER BY id ASC + """, (invoice_id,)) + items = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + invoice["created_at"] = fmt_local(invoice.get("created_at")) + + for item in items: + item["quantity"] = _fmt_money(item.get("quantity")) + item["unit_price"] = _fmt_money(item.get("unit_price")) + item["line_total"] = _fmt_money(item.get("line_total")) + + pdf_url = None + for candidate in ( + f"/invoices/{invoice_id}/pdf", + f"/invoice/{invoice_id}/pdf", + f"/invoices/{invoice_id}/print", + ): + if candidate in text_for_pdf_routes: + pdf_url = candidate + break + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) diff --git a/backend/app_deduped_test.py b/backend/app_deduped_test.py new file mode 100644 index 0000000..d4e8660 --- /dev/null +++ b/backend/app_deduped_test.py @@ -0,0 +1,3312 @@ +import os +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import zipfile +import smtplib +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +text_for_pdf_routes = """import os +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import zipfile +import smtplib +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +text_for_pdf_routes = "" + + +def load_version(): + try: + with open(BASE_DIR / "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}" + + +def ensure_subscriptions_table(): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS subscriptions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + client_id INT UNSIGNED NOT NULL, + service_id INT UNSIGNED NULL, + subscription_name VARCHAR(255) NOT NULL, + billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly', + price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, + currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD', + start_date DATE NOT NULL, + next_invoice_date DATE NOT NULL, + status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_subscriptions_client_id (client_id), + KEY idx_subscriptions_service_id (service_id), + KEY idx_subscriptions_status (status), + KEY idx_subscriptions_next_invoice_date (next_invoice_date) + ) + """) + conn.commit() + conn.close() + + +def get_next_subscription_date(current_date, billing_interval): + if isinstance(current_date, str): + current_date = datetime.strptime(current_date, "%Y-%m-%d").date() + + if billing_interval == "yearly": + return current_date + relativedelta(years=1) + if billing_interval == "quarterly": + return current_date + relativedelta(months=3) + return current_date + relativedelta(months=1) + + +def generate_due_subscription_invoices(run_date=None): + ensure_subscriptions_table() + + today = run_date or date.today() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + WHERE s.status = 'active' + AND s.next_invoice_date <= %s + ORDER BY s.next_invoice_date ASC, s.id ASC + """, (today,)) + due_subscriptions = cursor.fetchall() + + created_count = 0 + created_invoice_numbers = [] + + for sub in due_subscriptions: + invoice_number = generate_invoice_number() + due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time()) + + note_parts = [f"Recurring subscription: {sub['subscription_name']}"] + if sub.get("service_code"): + note_parts.append(f"Service: {sub['service_code']}") + if sub.get("service_name"): + note_parts.append(f"({sub['service_name']})") + if sub.get("notes"): + note_parts.append(f"Notes: {sub['notes']}") + + note_text = " ".join(note_parts) + + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO invoices + ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + subtotal_amount, + tax_amount, + issued_at, + due_at, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s) + """, ( + sub["client_id"], + sub["service_id"], + invoice_number, + sub["currency_code"], + str(sub["price"]), + str(sub["price"]), + due_dt, + note_text, + )) + + next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"]) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE subscriptions + SET next_invoice_date = %s + WHERE id = %s + """, (next_date, sub["id"])) + + created_count += 1 + created_invoice_numbers.append(invoice_number) + + conn.commit() + conn.close() + + return { + "created_count": created_count, + "invoice_numbers": created_invoice_numbers, + "run_date": str(today), + } + + +APP_SETTINGS_DEFAULTS = { + "business_name": "OTB Billing", + "business_tagline": "By a contractor, for contractors", + "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 build_accounting_package_bytes(): + import json + import zipfile + from io import BytesIO + + report = get_revenue_report_data() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.created_at, + c.company_name, + c.contact_name + FROM invoices i + JOIN clients c ON i.client_id = c.id + ORDER BY i.created_at DESC + """) + invoices = cursor.fetchall() + + conn.close() + + payload = { + "report": report, + "invoices": invoices + } + + json_bytes = json.dumps(payload, indent=2, default=str).encode() + + zip_buffer = BytesIO() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("revenue_report.json", json.dumps(report, indent=2)) + z.writestr("invoices.json", json.dumps(invoices, indent=2, default=str)) + + zip_buffer.seek(0) + + filename = f"accounting_package_{report.get('period_label','report')}.zip" + + return zip_buffer.read(), filename + + + +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("/subscriptions") +def subscriptions(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + s.*, + c.client_code, + c.company_name, + srv.service_code, + srv.service_name + FROM subscriptions s + JOIN clients c ON s.client_id = c.id + LEFT JOIN services srv ON s.service_id = srv.id + ORDER BY s.id DESC + """) + subscriptions = cursor.fetchall() + conn.close() + + return render_template("subscriptions/list.html", subscriptions=subscriptions) + + +@app.route("/subscriptions/new", methods=["GET", "POST"]) +def new_subscription(): + ensure_subscriptions_table() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_id = request.form.get("service_id", "").strip() + subscription_name = request.form.get("subscription_name", "").strip() + billing_interval = request.form.get("billing_interval", "").strip() + price = request.form.get("price", "").strip() + currency_code = request.form.get("currency_code", "").strip() + start_date_value = request.form.get("start_date", "").strip() + next_invoice_date = request.form.get("next_invoice_date", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not subscription_name: + errors.append("Subscription name is required.") + if billing_interval not in {"monthly", "quarterly", "yearly"}: + errors.append("Billing interval is required.") + if not price: + errors.append("Price is required.") + if not currency_code: + errors.append("Currency is required.") + if not start_date_value: + errors.append("Start date is required.") + if not next_invoice_date: + errors.append("Next invoice date is required.") + if status not in {"active", "paused", "cancelled"}: + errors.append("Status is required.") + + if not errors: + try: + price_value = Decimal(str(price)) + if price_value <= Decimal("0"): + errors.append("Price must be greater than zero.") + except Exception: + errors.append("Price must be a valid number.") + + if errors: + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=errors, + form_data={ + "client_id": client_id, + "service_id": service_id, + "subscription_name": subscription_name, + "billing_interval": billing_interval, + "price": price, + "currency_code": currency_code, + "start_date": start_date_value, + "next_invoice_date": next_invoice_date, + "status": status, + "notes": notes, + }, + ) + + insert_cursor = conn.cursor() + insert_cursor.execute(""" + INSERT INTO subscriptions + ( + client_id, + service_id, + subscription_name, + billing_interval, + price, + currency_code, + start_date, + next_invoice_date, + status, + notes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + client_id, + service_id or None, + subscription_name, + billing_interval, + str(price_value), + currency_code, + start_date_value, + next_invoice_date, + status, + notes or None, + )) + + conn.commit() + conn.close() + return redirect("/subscriptions") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") + clients = cursor.fetchall() + cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") + services = cursor.fetchall() + conn.close() + + today_str = date.today().isoformat() + + return render_template( + "subscriptions/new.html", + clients=clients, + services=services, + errors=[], + form_data={ + "billing_interval": "monthly", + "currency_code": "CAD", + "start_date": today_str, + "next_invoice_date": today_str, + "status": "active", + }, + ) + + +@app.route("/subscriptions/run", methods=["POST"]) +def run_subscriptions_now(): + result = generate_due_subscription_invoices() + return redirect(f"/subscriptions?run_count={result['created_count']}") + + + +@app.route("/reports/aging") +def report_aging(): + refresh_overdue_invoices() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + c.id AS client_id, + c.client_code, + c.company_name, + i.invoice_number, + i.due_at, + i.total_amount, + i.amount_paid, + (i.total_amount - i.amount_paid) AS remaining + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ORDER BY c.company_name, i.due_at + """) + rows = cursor.fetchall() + conn.close() + + today = datetime.utcnow().date() + grouped = {} + totals = { + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + for row in rows: + client_id = row["client_id"] + client_label = f"{row['client_code']} - {row['company_name']}" + + if client_id not in grouped: + grouped[client_id] = { + "client": client_label, + "current": Decimal("0"), + "d30": Decimal("0"), + "d60": Decimal("0"), + "d90": Decimal("0"), + "d90p": Decimal("0"), + "total": Decimal("0"), + } + + remaining = to_decimal(row["remaining"]) + + if row["due_at"]: + due_date = row["due_at"].date() + age_days = (today - due_date).days + else: + age_days = 0 + + if age_days <= 0: + bucket = "current" + elif age_days <= 30: + bucket = "d30" + elif age_days <= 60: + bucket = "d60" + elif age_days <= 90: + bucket = "d90" + else: + bucket = "d90p" + + grouped[client_id][bucket] += remaining + grouped[client_id]["total"] += remaining + + totals[bucket] += remaining + totals["total"] += remaining + + aging_rows = list(grouped.values()) + + return render_template( + "reports/aging.html", + aging_rows=aging_rows, + totals=totals + ) + + +@app.route("/") +def index(): + refresh_overdue_invoices() + + 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') + AND (total_amount - amount_paid) > 0 + """) + 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 = to_decimal(cursor.fetchone()["revenue_received"]) + + cursor.execute(""" + SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance + FROM invoices + WHERE status IN ('pending', 'partial', 'overdue') + AND (total_amount - amount_paid) > 0 + """) + outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"]) + + conn.close() + + app_settings = get_app_settings() + + return render_template( + "dashboard.html", + total_clients=total_clients, + active_services=active_services, + outstanding_invoices=outstanding_invoices, + outstanding_balance=outstanding_balance, + revenue_received=revenue_received, + app_settings=app_settings, + ) + +@app.route("/clients") +def clients(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + c.*, + COALESCE(( + SELECT SUM(i.total_amount - i.amount_paid) + FROM invoices i + WHERE i.client_id = c.id + AND i.status IN ('pending', 'partial', 'overdue') + AND (i.total_amount - i.amount_paid) > 0 + ), 0) AS outstanding_balance + FROM clients c + ORDER BY c.company_name + """) + clients = cursor.fetchall() + + conn.close() + return render_template("clients/list.html", clients=clients) + +@app.route("/clients/new", methods=["GET", "POST"]) +def new_client(): + if request.method == "POST": + company_name = request.form["company_name"] + contact_name = request.form["contact_name"] + email = request.form["email"] + phone = request.form["phone"] + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT MAX(id) AS last_id FROM clients") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + + client_code = generate_client_code(company_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO clients + (client_code, company_name, contact_name, email, phone) + VALUES (%s, %s, %s, %s, %s) + """, + (client_code, company_name, contact_name, email, phone) + ) + conn.commit() + conn.close() + + return redirect("/clients") + + return render_template("clients/new.html") + +@app.route("/clients/edit/", methods=["GET", "POST"]) +def edit_client(client_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + company_name = request.form.get("company_name", "").strip() + contact_name = request.form.get("contact_name", "").strip() + email = request.form.get("email", "").strip() + phone = request.form.get("phone", "").strip() + status = request.form.get("status", "").strip() + notes = request.form.get("notes", "").strip() + + errors = [] + + if not company_name: + errors.append("Company name is required.") + if not status: + errors.append("Status is required.") + + if errors: + cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) + client = cursor.fetchone() + 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 = str(BASE_DIR) + 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() + + cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) + service_row = cursor.fetchone() + service_name = (service_row or {}).get("service_name") or "Service" + + line_description = service_name + if notes: + line_description = f"{service_name} - {notes}" + + 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 + )) + + invoice_id = insert_cursor.lastrowid + + insert_cursor.execute(""" + INSERT INTO invoice_items + ( + invoice_id, + line_number, + item_type, + description, + quantity, + unit_amount, + line_total, + currency_code, + service_id + ) + VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) + """, ( + invoice_id, + line_description, + total_amount, + total_amount, + currency_code, + service_id + )) + + 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 = str(BASE_DIR) + 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) + + cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) + service_row = cursor.fetchone() + service_name = (service_row or {}).get("service_name") or "Service" + + line_description = service_name + if notes: + line_description = f"{service_name} - {notes}" + + 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 + )) + + update_cursor.execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_id,)) + update_cursor.execute(""" + INSERT INTO invoice_items + ( + invoice_id, + line_number, + item_type, + description, + quantity, + unit_amount, + line_total, + currency_code, + service_id + ) + VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) + """, ( + invoice_id, + line_description, + total_amount, + total_amount, + currency_code, + service_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=[]) + + +def _portal_current_client(): + client_id = session.get("portal_client_id") + if not client_id: + return None + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change + FROM clients + WHERE id = %s + LIMIT 1 + """, (client_id,)) + client = cursor.fetchone() + conn.close() + return client + +@app.route("/portal", methods=["GET"]) +def portal_index(): + if session.get("portal_client_id"): + return redirect("/portal/dashboard") + return render_template("portal_login.html") + +@app.route("/portal/login", methods=["POST"]) +def portal_login(): + email = (request.form.get("email") or "").strip().lower() + credential = (request.form.get("credential") or "").strip() + + if not email or not credential: + return render_template("portal_login.html", portal_message="Email and access code or password are required.", portal_email=email) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, + portal_password_hash, portal_force_password_change + FROM clients + WHERE LOWER(email) = %s + LIMIT 1 + """, (email,)) + client = cursor.fetchone() + + if not client or not client.get("portal_enabled"): + conn.close() + return render_template("portal_login.html", portal_message="Portal access is not enabled for that email address.", portal_email=email) + + password_hash = client.get("portal_password_hash") + access_code = client.get("portal_access_code") or "" + + ok = False + first_login = False + + if password_hash: + ok = check_password_hash(password_hash, credential) + else: + ok = (credential == access_code) + first_login = ok + + if not ok and access_code and credential == access_code: + ok = True + first_login = True + + if not ok: + conn.close() + return render_template("portal_login.html", portal_message="Invalid credentials.", portal_email=email) + + session["portal_client_id"] = client["id"] + session["portal_email"] = client["email"] + + cursor.execute(""" + UPDATE clients + SET portal_last_login_at = UTC_TIMESTAMP() + WHERE id = %s + """, (client["id"],)) + conn.commit() + conn.close() + + if first_login or client.get("portal_force_password_change"): + return redirect("/portal/set-password") + + return redirect("/portal/dashboard") + +@app.route("/portal/set-password", methods=["GET", "POST"]) +def portal_set_password(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + client_name = client.get("company_name") or client.get("contact_name") or client.get("email") + + if request.method == "GET": + return render_template("portal_set_password.html", client_name=client_name) + + password = (request.form.get("password") or "") + password2 = (request.form.get("password2") or "") + + if len(password) < 10: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Password must be at least 10 characters long.") + if password != password2: + return render_template("portal_set_password.html", client_name=client_name, portal_message="Passwords do not match.") + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET portal_password_hash = %s, + portal_password_set_at = UTC_TIMESTAMP(), + portal_force_password_change = 0, + portal_access_code = NULL + WHERE id = %s + """, (generate_password_hash(password), client["id"])) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + +@app.route("/portal/dashboard", methods=["GET"]) +def portal_dashboard(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE client_id = %s + ORDER BY created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + for row in invoices: + outstanding = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + row["outstanding"] = _fmt_money(outstanding) + row["total_amount"] = _fmt_money(row.get("total_amount")) + row["amount_paid"] = _fmt_money(row.get("amount_paid")) + row["created_at"] = fmt_local(row.get("created_at")) + + total_outstanding = sum((to_decimal(r["outstanding"]) for r in invoices), to_decimal("0")) + total_paid = sum((to_decimal(r["amount_paid"]) for r in invoices), to_decimal("0")) + + conn.close() + + return render_template( + "portal_dashboard.html", + client=client, + invoices=invoices, + invoice_count=len(invoices), + total_outstanding=f"{total_outstanding:.2f}", + total_paid=f"{total_paid:.2f}", + ) + + +@app.route("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + cursor.execute(""" + SELECT description, quantity, unit_amount AS unit_price, line_total + FROM invoice_items + WHERE invoice_id = %s + ORDER BY id ASC + """, (invoice_id,)) + items = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + invoice["created_at"] = fmt_local(invoice.get("created_at")) + + for item in items: + item["quantity"] = _fmt_money(item.get("quantity")) + item["unit_price"] = _fmt_money(item.get("unit_price")) + item["line_total"] = _fmt_money(item.get("line_total")) + + pdf_url = None + for candidate in ( + f"/invoices/{invoice_id}/pdf", + f"/invoice/{invoice_id}/pdf", + f"/invoices/{invoice_id}/print", + ): + if candidate in text_for_pdf_routes: + pdf_url = candidate + break + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) diff --git a/docs/db_reset_rebuild_reference.md b/docs/db_reset_rebuild_reference.md new file mode 100644 index 0000000..4abe33a --- /dev/null +++ b/docs/db_reset_rebuild_reference.md @@ -0,0 +1,101 @@ +# OTB Billing Database Reset / Rebuild Reference + +This is the current clean rebuild process for the otb_billing database. + +Important notes: +- Base schema is loaded from sql/schema_v0.0.2.sql +- Some tables are auto-created by the app at runtime +- Aging report does NOT require its own table + +Runtime-created tables: +- app_settings +- subscriptions +- email_log + +------------------------------------------------------------ + +Step 1 — Optional SQL backup + +cd /home/def/otb_billing || exit 1 + +mysqldump -u otb_billing -p'!2Eas678' otb_billing > test-backup-before-reset.sql + +------------------------------------------------------------ + +Step 2 — Drop and recreate the database + +cd /home/def/otb_billing || exit 1 + +sudo mysql <<'SQL' +DROP DATABASE IF EXISTS otb_billing; + +CREATE DATABASE otb_billing +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +CREATE USER IF NOT EXISTS 'otb_billing'@'localhost' +IDENTIFIED BY '!2Eas678'; + +ALTER USER 'otb_billing'@'localhost' +IDENTIFIED BY '!2Eas678'; + +GRANT ALL PRIVILEGES ON otb_billing.* TO 'otb_billing'@'localhost'; + +FLUSH PRIVILEGES; +SQL + +------------------------------------------------------------ + +Step 3 — Reload base schema + +cd /home/def/otb_billing || exit 1 + +mysql -u otb_billing -p'!2Eas678' otb_billing < sql/schema_v0.0.2.sql + +------------------------------------------------------------ + +Step 4 — Start the app + +cd /home/def/otb_billing || exit 1 + +./run_dev.sh + +------------------------------------------------------------ + +Step 5 — Trigger runtime-created tables + +Open these pages once: + +/settings +/subscriptions + +To create email_log send one test email. + +------------------------------------------------------------ + +Step 6 — Verify rebuild worked + +cd /home/def/otb_billing || exit 1 + +mysql -u otb_billing -p'!2Eas678' -D otb_billing -e " +SHOW TABLES; + +SELECT COUNT(*) AS clients FROM clients; +SELECT COUNT(*) AS invoices FROM invoices; +SELECT COUNT(*) AS payments FROM payments; +SELECT COUNT(*) AS services FROM services; +SELECT COUNT(*) AS credit_ledger FROM credit_ledger; +" + +------------------------------------------------------------ + +Expected key tables + +clients +services +invoices +payments +credit_ledger +app_settings +subscriptions +email_log diff --git a/scripts/invoice_reminder_worker.py b/scripts/invoice_reminder_worker.py new file mode 100755 index 0000000..36e9a22 --- /dev/null +++ b/scripts/invoice_reminder_worker.py @@ -0,0 +1,123 @@ +#!/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, recalc_invoice_totals + + +REMINDER_DAYS = 7 +OVERDUE_DAYS = 14 + + +def main(): + print(f"[{datetime.now().isoformat()}] invoice_reminder_worker starting") + checked_count = 0 + reminder_sent_count = 0 + overdue_sent_count = 0 + skipped_count = 0 + 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: +{recalc_invoice_totals(inv['id'])['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: +{recalc_invoice_totals(inv['id'])['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() + + + print(f"[{datetime.now().isoformat()}] checked={checked_count} reminders_sent={reminder_sent_count} overdue_sent={overdue_sent_count} skipped={skipped_count}") + +if __name__ == "__main__": + main() diff --git a/scripts/invoice_reminder_worker.py.bak_20260313-035553 b/scripts/invoice_reminder_worker.py.bak_20260313-035553 new file mode 100755 index 0000000..f54c292 --- /dev/null +++ b/scripts/invoice_reminder_worker.py.bak_20260313-035553 @@ -0,0 +1,110 @@ +#!/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 new file mode 100755 index 0000000..093c955 --- /dev/null +++ b/scripts/invoice_reminder_worker.py.bak_20260313-035724 @@ -0,0 +1,117 @@ +#!/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 new file mode 100755 index 0000000..ca92132 --- /dev/null +++ b/scripts/invoice_reminder_worker.py.bak_20260313-041145 @@ -0,0 +1,116 @@ +#!/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/base.html b/templates/base.html index 4e5f7ac..c6df824 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,6 +4,7 @@ {{ page_title }} + diff --git a/templates/clients/edit.html b/templates/clients/edit.html index f3cb1ab..dd53adc 100644 --- a/templates/clients/edit.html +++ b/templates/clients/edit.html @@ -2,6 +2,7 @@ Edit Client + @@ -76,6 +77,79 @@ Notes
+ +
+

Portal Access

+ +
+
Portal Enabled: {{ "Yes" if client.portal_enabled else "No" }}
+
Current Access Code: {{ client.portal_access_code or "Not set" }}
+
Password Set At: {{ client.portal_password_set_at or "Not set" }}
+
Access Code Created At: {{ client.portal_access_code_created_at or "Not set" }}
+
Last Portal Login: {{ client.portal_last_login_at or "Never" }}
+
+ + + + {% if request.args.get("portal_reset_status") == "sent" %} +
+ Portal password reset email sent successfully. +
+ {% elif request.args.get("portal_reset_status") == "missing_email" %} +
+ Portal password reset email was not sent because this client does not have an email address on file. +
+ {% elif request.args.get("portal_reset_status") == "error" %} +
+ Portal password reset email could not be sent. Check SMTP settings and server logs. +
+ {% endif %} + + {% if request.args.get("portal_email_status") == "sent" %} +
+ Portal invite email sent successfully. +
+ {% elif request.args.get("portal_email_status") == "missing_email" %} +
+ Portal invite email was not sent because this client does not have an email address on file. +
+ {% elif request.args.get("portal_email_status") == "error" %} +
+ Portal invite email could not be sent. Check SMTP settings and server logs. +
+ {% endif %} + +
+ {% if client.portal_enabled %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ Resetting the access code disables the current portal password and forces the client to set a new password on next login. + The portal access code is intended as a one-time access token and is cleared after successful password setup. +
+
+ + {% include "footer.html" %} diff --git a/templates/clients/list.html b/templates/clients/list.html index 8bec526..05548aa 100644 --- a/templates/clients/list.html +++ b/templates/clients/list.html @@ -3,6 +3,7 @@ Clients + diff --git a/templates/clients/new.html b/templates/clients/new.html index 8c2bff5..9a48915 100644 --- a/templates/clients/new.html +++ b/templates/clients/new.html @@ -2,6 +2,7 @@ New Client + diff --git a/templates/credits/add.html b/templates/credits/add.html index 7774c78..b1d2e47 100644 --- a/templates/credits/add.html +++ b/templates/credits/add.html @@ -2,6 +2,7 @@ Add Credit + diff --git a/templates/credits/list.html b/templates/credits/list.html index 4cdd0e3..cb88fd3 100644 --- a/templates/credits/list.html +++ b/templates/credits/list.html @@ -2,6 +2,7 @@ Client Credit Ledger + diff --git a/templates/dashboard.html b/templates/dashboard.html index d8712ed..5b6ee23 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -3,6 +3,7 @@ OTB Billing Dashboard + @@ -32,6 +33,8 @@ Services Invoices Payments + Accountbook + Square Reconciliation Subscriptions Revenue Report Aging Report diff --git a/templates/health.html b/templates/health.html index a2168df..197cc40 100644 --- a/templates/health.html +++ b/templates/health.html @@ -4,7 +4,7 @@ System Health - OTB Billing - + +
diff --git a/templates/invoices/edit.html b/templates/invoices/edit.html index 5112e43..b5f6650 100644 --- a/templates/invoices/edit.html +++ b/templates/invoices/edit.html @@ -37,6 +37,7 @@ margin-bottom: 15px; } + diff --git a/templates/invoices/list.html b/templates/invoices/list.html index af9802f..7f99aa0 100644 --- a/templates/invoices/list.html +++ b/templates/invoices/list.html @@ -53,6 +53,7 @@ select { flex-wrap: wrap; } + diff --git a/templates/invoices/new.html b/templates/invoices/new.html index 92523a6..c2d46d4 100644 --- a/templates/invoices/new.html +++ b/templates/invoices/new.html @@ -2,6 +2,7 @@ New Invoice + diff --git a/templates/invoices/print_batch.html b/templates/invoices/print_batch.html index 853fee0..aad4937 100644 --- a/templates/invoices/print_batch.html +++ b/templates/invoices/print_batch.html @@ -101,6 +101,7 @@ body { } } + diff --git a/templates/invoices/view.html b/templates/invoices/view.html index d9e7557..5ff998d 100644 --- a/templates/invoices/view.html +++ b/templates/invoices/view.html @@ -95,6 +95,7 @@ body { } } + @@ -231,5 +232,57 @@ body {
{% 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 +

+ + + +
+ {% if (invoice.status or "")|lower != "paid" %} + + Pay Now (Square) + + {% endif %} +
+ diff --git a/templates/invoices/view.html.square_button_20260313-055733.bak b/templates/invoices/view.html.square_button_20260313-055733.bak new file mode 100644 index 0000000..ed0e537 --- /dev/null +++ b/templates/invoices/view.html.square_button_20260313-055733.bak @@ -0,0 +1,256 @@ + + + +Invoice {{ invoice.invoice_number }} + + + + + +
+ {% if request.args.get('email_sent') == '1' %} +
+ Invoice email sent successfully. +
+ {% endif %} + {% if request.args.get('email_failed') == '1' %} +
+ Invoice email failed. Check SMTP settings or server log. +
+ {% endif %} + + +
+ +{% if settings.business_logo_url %} + +{% endif %} + +
+

Invoice {{ invoice.invoice_number }}

+ {{ invoice.status }} +
+
+ {{ settings.business_name or 'OTB Billing' }}
+ {{ settings.business_tagline or '' }}
+ {% if settings.business_address %}{{ settings.business_address }}
{% endif %} + {% if settings.business_email %}{{ settings.business_email }}
{% endif %} + {% if settings.business_phone %}{{ settings.business_phone }}
{% endif %} + {% if settings.business_website %}{{ settings.business_website }}{% endif %} +
+
+ +
+
+

Bill To

+ {{ invoice.company_name }}
+ {% if invoice.contact_name %}{{ invoice.contact_name }}
{% endif %} + {% if invoice.email %}{{ invoice.email }}
{% endif %} + {% if invoice.phone %}{{ invoice.phone }}
{% endif %} + Client Code: {{ invoice.client_code }} +
+ +
+

Invoice Details

+ Invoice #: {{ invoice.invoice_number }}
+ Issued: {{ invoice.issued_at|localtime }}
+ Due: {{ invoice.due_at|localtime }}
+ {% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}
{% endif %} + Currency: {{ invoice.currency_code }}
+ {% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}
{% endif %} + {% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %} +
+
+ + + + + + + + + + + + + + +
Service CodeServiceDescriptionTotal
{{ invoice.service_code or '-' }}{{ invoice.service_name or '-' }}{{ invoice.notes or '-' }}{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
+ + + + + + + + + + + + + + + + + + + + + + +
Subtotal{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
{{ settings.tax_label or 'Tax' }}{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
Total{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
Paid{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}
Remaining{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}
+ + {% if latest_email_log %} +
+ Latest Email Activity

+ Status: {{ latest_email_log.status }}
+ Recipient: {{ latest_email_log.recipient_email }}
+ Subject: {{ latest_email_log.subject }}
+ Sent At: {{ latest_email_log.sent_at|localtime }}
+ {% if latest_email_log.error_message %} + Error: {{ latest_email_log.error_message }} + {% endif %} +
+ {% endif %} + + {% if settings.payment_terms %} +
+ Payment Terms

+ {{ settings.payment_terms }} +
+ {% endif %} + + {% if settings.invoice_footer %} +
+ Footer

+ {{ settings.invoice_footer }} +
+ {% 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)
+Contact us for a secure Square payment link.

+ +

+If you have questions please contact +support@outsidethebox.top +

+ + + + diff --git a/templates/payments/edit.html b/templates/payments/edit.html index 507b7fc..10cb422 100644 --- a/templates/payments/edit.html +++ b/templates/payments/edit.html @@ -21,6 +21,7 @@ margin-bottom: 15px; } + diff --git a/templates/payments/list.html b/templates/payments/list.html index ab47b4d..cdfcbd0 100644 --- a/templates/payments/list.html +++ b/templates/payments/list.html @@ -46,6 +46,7 @@ opacity: 0.9; } + diff --git a/templates/payments/new.html b/templates/payments/new.html index b64c957..b96b089 100644 --- a/templates/payments/new.html +++ b/templates/payments/new.html @@ -21,6 +21,7 @@ margin-bottom: 15px; } + diff --git a/templates/portal_dashboard.html b/templates/portal_dashboard.html index 185daff..c0867a8 100644 --- a/templates/portal_dashboard.html +++ b/templates/portal_dashboard.html @@ -4,7 +4,7 @@ Client Dashboard - OutsideTheBox - + +
@@ -79,6 +80,7 @@

{{ client.company_name or client.contact_name or client.email }}

+ Download All Invoices (ZIP) Home Contact Support Logout diff --git a/templates/portal_forgot_password.html b/templates/portal_forgot_password.html new file mode 100644 index 0000000..eaa5f11 --- /dev/null +++ b/templates/portal_forgot_password.html @@ -0,0 +1,82 @@ + + + + + + Forgot Portal Password - OutsideTheBox + + + + + +
+
+

Reset Portal Password

+

Enter your email address and a new single-use access code will be sent if your account exists.

+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if message %} +
{{ message }}
+ {% endif %} + +
+
+ + +
+ +
+ + Back to Portal Login + Contact Support +
+
+
+
+ +{% include "footer.html" %} + + diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index 018aa46..94d9533 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -4,7 +4,7 @@ Invoice Detail - OutsideTheBox - + +
@@ -92,6 +93,13 @@
+ + {% if (invoice.status or "")|lower == "paid" %} +
+ ✓ This invoice has been paid. Thank you! +
+ {% endif %} +

Invoice

@@ -154,6 +162,32 @@ + + {% if (invoice.status or "")|lower != "paid" %} +
+

Payment Instructions

+ +

Interac e-Transfer
+ Send payment to:
+ payment@outsidethebox.top
+ Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }} +

+ +

Credit Card (Square)
+ + Pay Now +
+ Please include your invoice number in the payment note. +

+ +

+ If you have questions please contact + support@outsidethebox.top +

+
+ {% endif %} + {% if pdf_url %}
Open Invoice PDF diff --git a/templates/portal_invoice_detail.html.bak_20260314-020444 b/templates/portal_invoice_detail.html.bak_20260314-020444 new file mode 100644 index 0000000..1bb442b --- /dev/null +++ b/templates/portal_invoice_detail.html.bak_20260314-020444 @@ -0,0 +1,209 @@ + + + + + + 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_invoice_detail.html.square_button_20260313-055733.bak b/templates/portal_invoice_detail.html.square_button_20260313-055733.bak new file mode 100644 index 0000000..6d1a444 --- /dev/null +++ b/templates/portal_invoice_detail.html.square_button_20260313-055733.bak @@ -0,0 +1,187 @@ + + + + + + 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)
+Contact us for a secure Square payment link.

+ +

+If you have questions please contact +support@outsidethebox.top +

+ + + + diff --git a/templates/portal_login.html b/templates/portal_login.html index 7585ab4..b6a3e4b 100644 --- a/templates/portal_login.html +++ b/templates/portal_login.html @@ -4,7 +4,7 @@ Client Portal - OutsideTheBox - + +
@@ -78,8 +79,14 @@
+ + +

First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. + This access code is single-use and is cleared after password setup. Future logins use your email address and password.

diff --git a/templates/portal_set_password.html b/templates/portal_set_password.html index fae268f..7d304db 100644 --- a/templates/portal_set_password.html +++ b/templates/portal_set_password.html @@ -4,7 +4,7 @@ Set Portal Password - OutsideTheBox - + +
diff --git a/templates/reports/aging.html b/templates/reports/aging.html index b395724..8691617 100644 --- a/templates/reports/aging.html +++ b/templates/reports/aging.html @@ -47,6 +47,7 @@ th { background: #f8f8f8; } + diff --git a/templates/reports/revenue.html b/templates/reports/revenue.html index d22cb3f..91c9bb6 100644 --- a/templates/reports/revenue.html +++ b/templates/reports/revenue.html @@ -26,6 +26,7 @@ body { font-family: Arial, sans-serif; } margin-right: 16px; } + diff --git a/templates/reports/revenue_print.html b/templates/reports/revenue_print.html index b5fb6b1..8647d2b 100644 --- a/templates/reports/revenue_print.html +++ b/templates/reports/revenue_print.html @@ -21,6 +21,7 @@ th, td { body { margin: 0; } } + diff --git a/templates/services/edit.html b/templates/services/edit.html index 2f66fdc..6e10945 100644 --- a/templates/services/edit.html +++ b/templates/services/edit.html @@ -2,6 +2,7 @@ Edit Service + diff --git a/templates/services/list.html b/templates/services/list.html index 65351ee..3151601 100644 --- a/templates/services/list.html +++ b/templates/services/list.html @@ -2,6 +2,7 @@ Services + diff --git a/templates/services/new.html b/templates/services/new.html index c5898ba..fba0a04 100644 --- a/templates/services/new.html +++ b/templates/services/new.html @@ -2,6 +2,7 @@ New Service + diff --git a/templates/settings.html b/templates/settings.html index aeabb82..b2e7db7 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -50,6 +50,7 @@ small { color: #444; } + diff --git a/templates/subscriptions/list.html b/templates/subscriptions/list.html index 757208a..e184c66 100644 --- a/templates/subscriptions/list.html +++ b/templates/subscriptions/list.html @@ -7,6 +7,7 @@ .status-paused { color: #92400e; font-weight: bold; } .status-cancelled { color: #991b1b; font-weight: bold; } + diff --git a/templates/subscriptions/new.html b/templates/subscriptions/new.html index 3ef4e6a..863f1d3 100644 --- a/templates/subscriptions/new.html +++ b/templates/subscriptions/new.html @@ -2,6 +2,7 @@ New Subscription +