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}
+
+
+ """)
+
+ 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}
+
+
+
+
+ Logged At (UTC)
+ Event
+ Payment ID
+ Amount (cents)
+ Note
+ Sig Valid
+ Auto Apply Result
+
+
+
+ {''.join(rows_html) if rows_html else '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
+
+
+
+
+
+
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 %}
+
+
+
+
+
+
+
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 Code
+ Service
+ Description
+ Total
+
+
+ {{ 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 %}
+
+
+
+
+
+{% 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
+
+
+
+ Description
+ Qty
+ Unit Price
+ Line Total
+
+
+
+ {% for item in items %}
+
+ {{ item.description }}
+ {{ item.quantity }}
+ {{ item.unit_price }}
+ {{ item.line_total }}
+
+ {% else %}
+
+ No invoice line items found.
+
+ {% endfor %}
+
+
+
+ {% if pdf_url %}
+
+ {% endif %}
+
+
+{% include "footer.html" %}
+
+
+
+
Payment Instructions
+
+
Interac e-Transfer
+Send payment to:
+payment@outsidethebox.top
+Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
+
+
+
Credit Card (Square)
+
+
+
+
Credit Card (Square)
+
+
+Pay with Card (Square)
+
+
+
+You will be redirected to Square's secure payment page.
+Please include your invoice number in the payment note.
+
+
+
+
+If you have questions please contact
+support@outsidethebox.top
+
+
+
+
+
diff --git a/templates/portal_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
+
+
+
+ Description
+ Qty
+ Unit Price
+ Line Total
+
+
+
+ {% for item in items %}
+
+ {{ item.description }}
+ {{ item.quantity }}
+ {{ item.unit_price }}
+ {{ item.line_total }}
+
+ {% else %}
+
+ No invoice line items found.
+
+ {% endfor %}
+
+
+
+ {% if pdf_url %}
+
+ {% endif %}
+
+
+{% include "footer.html" %}
+
+
+
+
Payment Instructions
+
+
Interac e-Transfer
+Send payment to:
+payment@outsidethebox.top
+Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
+
+
+
Credit Card (Square)
+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
+