diff --git a/README.md b/README.md index 06390e3..2b3cee0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +## 2026-03-27 — v0.5.1 + +- Fixed crypto payment email auto-send failure +- Replaced internal PDF generator call with route-based PDF fetch +- Restored PDF attachments in payment emails +- Improved Payments Applied layout in invoice PDF (multi-line details + rate display) +- Stabilized send_payment_received_email() (removed debug raise, safe failure handling) + + ## v0.5.0 - 2026-03-14 22:01:59 - Added per-invoice Square payment links diff --git a/VERSION b/VERSION index b043aa6..992ac75 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.5.0 +v0.5.1 diff --git a/backend/app.py.bak_accounting_builder b/backend/app-backups/app.py.bak_accounting_builder similarity index 100% rename from backend/app.py.bak_accounting_builder rename to backend/app-backups/app.py.bak_accounting_builder diff --git a/backend/app.py.bak_email_log_safe b/backend/app-backups/app.py.bak_email_log_safe similarity index 100% rename from backend/app.py.bak_email_log_safe rename to backend/app-backups/app.py.bak_email_log_safe diff --git a/backend/app.py.bak_payments_query_fix b/backend/app-backups/app.py.bak_payments_query_fix similarity index 100% rename from backend/app.py.bak_payments_query_fix rename to backend/app-backups/app.py.bak_payments_query_fix diff --git a/backend/app.py.bak_payments_route_exact_fix b/backend/app-backups/app.py.bak_payments_route_exact_fix similarity index 100% rename from backend/app.py.bak_payments_route_exact_fix rename to backend/app-backups/app.py.bak_payments_route_exact_fix diff --git a/backend/app.py.bak_payments_route_fix2 b/backend/app-backups/app.py.bak_payments_route_fix2 similarity index 100% rename from backend/app.py.bak_payments_route_fix2 rename to backend/app-backups/app.py.bak_payments_route_fix2 diff --git a/backend/app.py.bak_report_email_fix b/backend/app-backups/app.py.bak_report_email_fix similarity index 100% rename from backend/app.py.bak_report_email_fix rename to backend/app-backups/app.py.bak_report_email_fix diff --git a/backend/app.py.bak_void_fix b/backend/app-backups/app.py.bak_void_fix similarity index 100% rename from backend/app.py.bak_void_fix rename to backend/app-backups/app.py.bak_void_fix diff --git a/backend/app.py b/backend/app.py index aefd467..5d1aa55 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,4 +1,7 @@ import os +from dotenv import load_dotenv +load_dotenv() + 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 @@ -39,11 +42,15 @@ app = Flask( ) app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection +TERMS_VERSION = "v1.0" + 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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") 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", "") @@ -72,6 +79,14 @@ CRYPTO_WATCHER_STARTED = False + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + def load_version(): try: with open(BASE_DIR / "VERSION", "r") as f: @@ -641,6 +656,12 @@ def reconcile_pending_crypto_payment(payment_row): except Exception: pass + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + return {"state": "confirmed", "confirmations": confirmations or 1} notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") @@ -870,9 +891,13 @@ def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): cursor = conn.cursor(dictionary=True) cursor.execute(""" - SELECT notes - FROM payments - WHERE id = %s + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s LIMIT 1 """, (payment_id,)) row = cursor.fetchone() @@ -881,29 +906,147 @@ def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): conn.close() return + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + new_notes = append_payment_note( row.get("notes"), - f"[crypto watcher] confirmed via {rpc_url}" + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") ) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'confirmed', - confirmations = COALESCE(confirmations, 1), - confirmation_required = COALESCE(confirmation_required, 1), + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), received_at = COALESCE(received_at, UTC_TIMESTAMP()), notes = %s WHERE id = %s """, (new_notes, payment_id)) conn.commit() - conn.close() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass try: recalc_invoice_totals(invoice_id) except Exception: pass + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() def watch_pending_crypto_payments_once(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -980,17 +1123,9 @@ def crypto_payment_watcher_loop(): time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) def start_crypto_payment_watcher(): - global CRYPTO_WATCHER_STARTED - if CRYPTO_WATCHER_STARTED: - return - - t = threading.Thread( - target=crypto_payment_watcher_loop, - name="crypto-payment-watcher", - daemon=True, - ) - t.start() - CRYPTO_WATCHER_STARTED = True + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1"))) @@ -1093,6 +1228,120 @@ def refresh_overdue_invoices(): conn.commit() conn.close() + +def send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + with app.app_context(): + with app.test_client() as client: + pdf_response = client.get(f"/invoices/pdf/{invoice_id}") + if pdf_response.status_code != 200: + raise Exception(f"PDF route failed: {pdf_response.status_code}") + pdf_bytes = pdf_response.data + + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + def recalc_invoice_totals(invoice_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -1679,6 +1928,9 @@ def send_configured_email(to_email, subject, body, attachments=None, email_type= @app.route("/settings", methods=["GET", "POST"]) def settings(): + gate = admin_required() + if gate: + return gate ensure_app_settings_table() if request.method == "POST": @@ -1703,6 +1955,9 @@ def accounting_package_zip(): @app.route("/reports/revenue") def revenue_report(): + gate = admin_required() + if gate: + return gate report = get_revenue_report_data() return render_template("reports/revenue.html", report=report) @@ -1857,6 +2112,9 @@ def email_accounting_package(): @app.route("/subscriptions") def subscriptions(): + gate = admin_required() + if gate: + return gate ensure_subscriptions_table() conn = get_db_connection() @@ -2016,6 +2274,9 @@ def run_subscriptions_now(): @app.route("/reports/aging") def report_aging(): + gate = admin_required() + if gate: + return gate refresh_overdue_invoices() conn = get_db_connection() @@ -2102,6 +2363,9 @@ def report_aging(): @app.route("/") def index(): + gate = admin_required() + if gate: + return gate refresh_overdue_invoices() conn = get_db_connection() @@ -2150,8 +2414,48 @@ def index(): app_settings=app_settings, ) + +@app.route("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + @app.route("/clients") def clients(): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -2175,6 +2479,9 @@ def clients(): @app.route("/clients/new", methods=["GET", "POST"]) def new_client(): + gate = admin_required() + if gate: + return gate if request.method == "POST": company_name = request.form["company_name"] contact_name = request.form["contact_name"] @@ -2207,6 +2514,9 @@ def new_client(): @app.route("/clients/edit/", methods=["GET", "POST"]) def edit_client(client_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -2268,6 +2578,9 @@ def edit_client(client_id): @app.route("/credits/") def client_credits(client_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -2372,6 +2685,9 @@ def add_credit(client_id): @app.route("/services") def services(): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" @@ -2386,6 +2702,9 @@ def services(): @app.route("/services/new", methods=["GET", "POST"]) def new_service(): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -2448,6 +2767,9 @@ def new_service(): @app.route("/services/edit/", methods=["GET", "POST"]) def edit_service(service_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -3007,6 +3329,9 @@ def print_invoices(): @app.route("/invoices") def invoices(): + gate = admin_required() + if gate: + return gate refresh_overdue_invoices() start_date = (request.args.get("start_date") or "").strip() @@ -3107,6 +3432,9 @@ def invoices(): @app.route("/invoices/new", methods=["GET", "POST"]) def new_invoice(): + gate = admin_required() + if gate: + return gate ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -3296,6 +3624,7 @@ def invoice_pdf(invoice_id): conn.close() settings = get_app_settings() + invoice_payments = get_invoice_payments(invoice_id) buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=letter) @@ -3464,6 +3793,20 @@ def invoice_pdf(invoice_id): pdf.showPage() y = height - 50 + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + pdf.setFont("Helvetica-Bold", 10) pdf.drawString( left, @@ -3472,27 +3815,52 @@ def invoice_pdf(invoice_id): ) y -= 13 - details_parts = [] + pdf.setFont("Helvetica", 9) + if p.get("received_at_local"): - details_parts.append(f"At: {p.get('received_at_local')}") - if p.get("txid"): - details_parts.append(f"TXID: {p.get('txid')}") + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 elif p.get("reference"): - details_parts.append(f"Ref: {p.get('reference')}") - if p.get("wallet_address"): - details_parts.append(f"Wallet: {p.get('wallet_address')}") + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 - details = " | ".join(details_parts) - if details: - pdf.setFont("Helvetica", 9) - for chunk_start in range(0, len(details), 108): + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): if y < 95: pdf.showPage() y = height - 50 pdf.setFont("Helvetica", 9) - pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) y -= 11 + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + y -= 6 if settings.get("invoice_footer"): @@ -3557,6 +3925,9 @@ def view_invoice(invoice_id): @app.route("/invoices/edit/", methods=["GET", "POST"]) def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -3800,6 +4171,9 @@ def export_payments_csv(): @app.route("/payments") def payments(): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -3825,6 +4199,9 @@ def payments(): @app.route("/payments/new", methods=["GET", "POST"]) def new_payment(): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -4009,46 +4386,11 @@ def new_payment(): 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 + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise return redirect("/payments") @@ -4120,6 +4462,9 @@ def void_payment(payment_id): @app.route("/payments/edit/", methods=["GET", "POST"]) def edit_payment(payment_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -4227,6 +4572,14 @@ def edit_payment(payment_id): return render_template("payments/edit.html", payment=payment, errors=[]) + +def portal_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + def _portal_current_client(): client_id = session.get("portal_client_id") if not client_id: @@ -4235,7 +4588,8 @@ def _portal_current_client(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" - SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change + SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version FROM clients WHERE id = %s LIMIT 1 @@ -4262,7 +4616,8 @@ def portal_login(): 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 + portal_password_hash, portal_force_password_change, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version FROM clients WHERE LOWER(email) = %s LIMIT 1 @@ -4307,6 +4662,9 @@ def portal_login(): if first_login or client.get("portal_force_password_change"): return redirect("/portal/set-password") + if portal_terms_required(client): + return redirect("/portal/terms") + return redirect("/portal/dashboard") @app.route("/portal/set-password", methods=["GET", "POST"]) @@ -4391,30 +4749,80 @@ def portal_dashboard(): client = _portal_current_client() if not client: return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") 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 + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC """, (client["id"],)) invoices = cursor.fetchall() def _fmt_money(value): return f"{to_decimal(value):.2f}" + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + for row in invoices: - outstanding = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) 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")) + client_credit_balance = get_client_credit_balance(client["id"]) conn.close() @@ -4425,15 +4833,17 @@ def portal_dashboard(): invoice_count=len(invoices), total_outstanding=f"{total_outstanding:.2f}", total_paid=f"{total_paid:.2f}", + client_credit_balance=f"{client_credit_balance:.2f}", ) - @app.route("/portal/invoice//pdf", methods=["GET"]) def portal_invoice_pdf(invoice_id): client = _portal_current_client() if not client: return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -4643,6 +5053,8 @@ def portal_invoice_detail(invoice_id): client = _portal_current_client() if not client: return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") ensure_invoice_quote_columns() conn = get_db_connection() @@ -4672,7 +5084,8 @@ def portal_invoice_detail(invoice_id): def _fmt_money(value): return f"{to_decimal(value):.2f}" - outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") invoice["outstanding"] = _fmt_money(outstanding) invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) @@ -4833,7 +5246,8 @@ def portal_invoice_detail(invoice_id): pending_crypto_payment = refreshed_payment if reconcile_result.get("state") == "confirmed": - outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") invoice["outstanding"] = _fmt_money(outstanding) invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) @@ -4862,6 +5276,51 @@ def portal_invoice_detail(invoice_id): ) + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + @app.route("/portal/logout", methods=["GET"]) def portal_logout(): session.pop("portal_client_id", None) @@ -4872,6 +5331,9 @@ def portal_logout(): @app.route("/clients/portal/enable/", methods=["POST"]) def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -4912,6 +5374,9 @@ def client_portal_enable(client_id): @app.route("/clients/portal/disable/", methods=["POST"]) def client_portal_disable(client_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" @@ -4925,6 +5390,9 @@ def client_portal_disable(client_id): @app.route("/clients/portal/reset-code/", methods=["POST"]) def client_portal_reset_code(client_id): + gate = admin_required() + if gate: + return gate new_code = generate_portal_access_code() conn = get_db_connection() @@ -4947,6 +5415,9 @@ def client_portal_reset_code(client_id): @app.route("/clients/portal/send-invite/", methods=["POST"]) def client_portal_send_invite(client_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -5066,6 +5537,9 @@ OutsideTheBox @app.route("/clients/portal/send-password-reset/", methods=["POST"]) def client_portal_send_password_reset(client_id): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -5660,44 +6134,8 @@ def auto_apply_square_payment(parsed_event): 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"] - ) + send_payment_received_email(invoice["id"], payment_amount_display) except Exception: pass @@ -5824,6 +6262,9 @@ def accountbook_export_csv(): @app.route("/accountbook") def accountbook(): + gate = admin_required() + if gate: + return gate conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" @@ -6231,3 +6672,16 @@ def square_webhook(): register_health_routes(app) if __name__ == "__main__": app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.attach-debug.20260326-020213.bak b/backend/app.py.attach-debug.20260326-020213.bak new file mode 100644 index 0000000..8426d0b --- /dev/null +++ b/backend/app.py.attach-debug.20260326-020213.bak @@ -0,0 +1,6603 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() diff --git a/backend/app.py.attachment-fix.20260326-021226.bak b/backend/app.py.attachment-fix.20260326-021226.bak new file mode 100644 index 0000000..8426d0b --- /dev/null +++ b/backend/app.py.attachment-fix.20260326-021226.bak @@ -0,0 +1,6603 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() diff --git a/backend/app.py.correct-payment-block.20260326-043806.bak b/backend/app.py.correct-payment-block.20260326-043806.bak new file mode 100644 index 0000000..70b8872 --- /dev/null +++ b/backend/app.py.correct-payment-block.20260326-043806.bak @@ -0,0 +1,6642 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.email-attachment.20260325-022911.bak b/backend/app.py.email-attachment.20260325-022911.bak new file mode 100644 index 0000000..cbf0879 --- /dev/null +++ b/backend/app.py.email-attachment.20260325-022911.bak @@ -0,0 +1,6582 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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(""" + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) \ No newline at end of file diff --git a/backend/app.py.email-attachment.20260325-023107.bak b/backend/app.py.email-attachment.20260325-023107.bak new file mode 100644 index 0000000..e9966fa --- /dev/null +++ b/backend/app.py.email-attachment.20260325-023107.bak @@ -0,0 +1,6596 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=[{ + "filename": f"invoice_{invoice.get('invoice_number')}.pdf", + "content": generate_invoice_pdf_bytes(invoice_id), + "mime": "application/pdf" + }], + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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(""" + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() diff --git a/backend/app.py.email-body-explorer-fix.20260326-033501.bak b/backend/app.py.email-body-explorer-fix.20260326-033501.bak new file mode 100644 index 0000000..52fcdb6 --- /dev/null +++ b/backend/app.py.email-body-explorer-fix.20260326-033501.bak @@ -0,0 +1,6663 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + payment_rate_line = None + try: + amount_for_rate = to_decimal(p.get("payment_amount") or p.get("payment_amount_display") or "0") + cad_for_rate = p.get("cad_value_at_payment") + if cad_for_rate not in (None, "", 0, "0") and amount_for_rate > 0: + payment_rate_line = f"Rate: 1 {p.get('payment_currency', '')} = {(to_decimal(cad_for_rate) / amount_for_rate):.6f} CAD" + except Exception: + payment_rate_line = None + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 12, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + explorer_url = None + txid_value = p.get("txid") + network_hint = str(p.get("payment_currency") or p.get("payment_method_label") or "").upper() + + if txid_value: + if "ETHO" in network_hint: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in network_hint or "EGAZ" in network_hint or "ETICA" in network_hint: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif network_hint == "ETH" or "ETHEREUM" in network_hint: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in network_hint or "ARBITRUM" in network_hint: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 12, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 12, y - 2, min(right, left + 12 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + pdf.drawString(left + 12, y, f"Ref: {p.get('reference')}") + y -= 11 + + if p.get("wallet_address"): + pdf.drawString(left + 12, y, f"Wallet: {p.get('wallet_address')}") + y -= 11 + + if payment_rate_line: + pdf.drawString(left + 12, y, payment_rate_line) + y -= 11 + + y -= 4 + details_parts = [] + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.email-body-fix-clean.20260326-040645.bak b/backend/app.py.email-body-fix-clean.20260326-040645.bak new file mode 100644 index 0000000..1630836 --- /dev/null +++ b/backend/app.py.email-body-fix-clean.20260326-040645.bak @@ -0,0 +1,6698 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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')}" + explorer_lines = [] + try: + payments = get_invoice_payments(invoice_id) + if payments: + p = payments[-1] + txid = p.get("txid") + payment_currency = str(p.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_lines.append(f"Transaction ID: +{txid}") + if explorer_url: + explorer_lines.append(f"View on explorer: +{explorer_url}") + except Exception: + pass + + explorer_block = "" + if explorer_lines: + explorer_block = " + +" + " + +".join(explorer_lines) + + 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')}{explorer_block} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + payment_rate_line = None + try: + amount_for_rate = to_decimal(p.get("payment_amount") or p.get("payment_amount_display") or "0") + cad_for_rate = p.get("cad_value_at_payment") + if cad_for_rate not in (None, "", 0, "0") and amount_for_rate > 0: + payment_rate_line = f"Rate: 1 {p.get('payment_currency', '')} = {(to_decimal(cad_for_rate) / amount_for_rate):.6f} CAD" + except Exception: + payment_rate_line = None + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 12, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + explorer_url = None + txid_value = p.get("txid") + network_hint = str(p.get("payment_currency") or p.get("payment_method_label") or "").upper() + + if txid_value: + if "ETHO" in network_hint: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in network_hint or "EGAZ" in network_hint or "ETICA" in network_hint: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif network_hint == "ETH" or "ETHEREUM" in network_hint: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in network_hint or "ARBITRUM" in network_hint: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 12, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 12, y - 2, min(right, left + 12 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + pdf.drawString(left + 12, y, f"Ref: {p.get('reference')}") + y -= 11 + + if p.get("wallet_address"): + pdf.drawString(left + 12, y, f"Wallet: {p.get('wallet_address')}") + y -= 11 + + if payment_rate_line: + pdf.drawString(left + 12, y, payment_rate_line) + y -= 11 + + y -= 4 + details_parts = [] + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.email-explorer-safe.20260326-040915.bak b/backend/app.py.email-explorer-safe.20260326-040915.bak new file mode 100644 index 0000000..76c10fe --- /dev/null +++ b/backend/app.py.email-explorer-safe.20260326-040915.bak @@ -0,0 +1,6610 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.envload.20260326-025333.bak b/backend/app.py.envload.20260326-025333.bak new file mode 100644 index 0000000..f5cc3b7 --- /dev/null +++ b/backend/app.py.envload.20260326-025333.bak @@ -0,0 +1,6604 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() diff --git a/backend/app.py.explorer-links.20260326-032929.bak b/backend/app.py.explorer-links.20260326-032929.bak new file mode 100644 index 0000000..52fcdb6 --- /dev/null +++ b/backend/app.py.explorer-links.20260326-032929.bak @@ -0,0 +1,6663 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + payment_rate_line = None + try: + amount_for_rate = to_decimal(p.get("payment_amount") or p.get("payment_amount_display") or "0") + cad_for_rate = p.get("cad_value_at_payment") + if cad_for_rate not in (None, "", 0, "0") and amount_for_rate > 0: + payment_rate_line = f"Rate: 1 {p.get('payment_currency', '')} = {(to_decimal(cad_for_rate) / amount_for_rate):.6f} CAD" + except Exception: + payment_rate_line = None + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 12, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + explorer_url = None + txid_value = p.get("txid") + network_hint = str(p.get("payment_currency") or p.get("payment_method_label") or "").upper() + + if txid_value: + if "ETHO" in network_hint: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in network_hint or "EGAZ" in network_hint or "ETICA" in network_hint: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif network_hint == "ETH" or "ETHEREUM" in network_hint: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in network_hint or "ARBITRUM" in network_hint: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 12, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 12, y - 2, min(right, left + 12 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + pdf.drawString(left + 12, y, f"Ref: {p.get('reference')}") + y -= 11 + + if p.get("wallet_address"): + pdf.drawString(left + 12, y, f"Wallet: {p.get('wallet_address')}") + y -= 11 + + if payment_rate_line: + pdf.drawString(left + 12, y, payment_rate_line) + y -= 11 + + y -= 4 + details_parts = [] + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.explorer-links.20260326-032943.bak b/backend/app.py.explorer-links.20260326-032943.bak new file mode 100644 index 0000000..52fcdb6 --- /dev/null +++ b/backend/app.py.explorer-links.20260326-032943.bak @@ -0,0 +1,6663 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + payment_rate_line = None + try: + amount_for_rate = to_decimal(p.get("payment_amount") or p.get("payment_amount_display") or "0") + cad_for_rate = p.get("cad_value_at_payment") + if cad_for_rate not in (None, "", 0, "0") and amount_for_rate > 0: + payment_rate_line = f"Rate: 1 {p.get('payment_currency', '')} = {(to_decimal(cad_for_rate) / amount_for_rate):.6f} CAD" + except Exception: + payment_rate_line = None + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 12, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + explorer_url = None + txid_value = p.get("txid") + network_hint = str(p.get("payment_currency") or p.get("payment_method_label") or "").upper() + + if txid_value: + if "ETHO" in network_hint: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in network_hint or "EGAZ" in network_hint or "ETICA" in network_hint: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif network_hint == "ETH" or "ETHEREUM" in network_hint: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in network_hint or "ARBITRUM" in network_hint: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 12, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 12, y - 2, min(right, left + 12 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + pdf.drawString(left + 12, y, f"Ref: {p.get('reference')}") + y -= 11 + + if p.get("wallet_address"): + pdf.drawString(left + 12, y, f"Wallet: {p.get('wallet_address')}") + y -= 11 + + if payment_rate_line: + pdf.drawString(left + 12, y, payment_rate_line) + y -= 11 + + y -= 4 + details_parts = [] + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.final-email-cleanup.20260327-020603.bak b/backend/app.py.final-email-cleanup.20260327-020603.bak new file mode 100644 index 0000000..7872ea4 --- /dev/null +++ b/backend/app.py.final-email-cleanup.20260327-020603.bak @@ -0,0 +1,6688 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + with app.app_context(): + with app.test_client() as client: + pdf_response = client.get(f"/invoices/pdf/{invoice_id}") + if pdf_response.status_code != 200: + raise Exception(f"PDF route failed: {pdf_response.status_code}") + pdf_bytes = pdf_response.data + + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.fix-missing-pdf-import.20260326-050344.bak b/backend/app.py.fix-missing-pdf-import.20260326-050344.bak new file mode 100644 index 0000000..aa6048a --- /dev/null +++ b/backend/app.py.fix-missing-pdf-import.20260326-050344.bak @@ -0,0 +1,6682 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.helper-pdf-route-fix.20260327-015238.bak b/backend/app.py.helper-pdf-route-fix.20260327-015238.bak new file mode 100644 index 0000000..aa6048a --- /dev/null +++ b/backend/app.py.helper-pdf-route-fix.20260327-015238.bak @@ -0,0 +1,6682 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.helper-rebuild.20260326-050853.bak b/backend/app.py.helper-rebuild.20260326-050853.bak new file mode 100644 index 0000000..aa6048a --- /dev/null +++ b/backend/app.py.helper-rebuild.20260326-050853.bak @@ -0,0 +1,6682 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.invoice-pdf-order-fix.20260326-041449.bak b/backend/app.py.invoice-pdf-order-fix.20260326-041449.bak new file mode 100644 index 0000000..874a8ad --- /dev/null +++ b/backend/app.py.invoice-pdf-order-fix.20260326-041449.bak @@ -0,0 +1,6641 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.invoice-pdf-payments-fix.20260326-030703.bak b/backend/app.py.invoice-pdf-payments-fix.20260326-030703.bak new file mode 100644 index 0000000..76c10fe --- /dev/null +++ b/backend/app.py.invoice-pdf-payments-fix.20260326-030703.bak @@ -0,0 +1,6610 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-block-global-fix.20260326-043120.bak b/backend/app.py.payment-block-global-fix.20260326-043120.bak new file mode 100644 index 0000000..70b8872 --- /dev/null +++ b/backend/app.py.payment-block-global-fix.20260326-043120.bak @@ -0,0 +1,6642 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-block-live-fix.20260326-044117.bak b/backend/app.py.payment-block-live-fix.20260326-044117.bak new file mode 100644 index 0000000..70b8872 --- /dev/null +++ b/backend/app.py.payment-block-live-fix.20260326-044117.bak @@ -0,0 +1,6642 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-block-polish.20260326-032040.bak b/backend/app.py.payment-block-polish.20260326-032040.bak new file mode 100644 index 0000000..96d899d --- /dev/null +++ b/backend/app.py.payment-block-polish.20260326-032040.bak @@ -0,0 +1,6611 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-email-and-pdf-fix.20260326-041126.bak b/backend/app.py.payment-email-and-pdf-fix.20260326-041126.bak new file mode 100644 index 0000000..874a8ad --- /dev/null +++ b/backend/app.py.payment-email-and-pdf-fix.20260326-041126.bak @@ -0,0 +1,6641 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-email-logging.20260326-045639.bak b/backend/app.py.payment-email-logging.20260326-045639.bak new file mode 100644 index 0000000..3532f21 --- /dev/null +++ b/backend/app.py.payment-email-logging.20260326-045639.bak @@ -0,0 +1,6681 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-layout-fix.20260327-013334.bak b/backend/app.py.payment-layout-fix.20260327-013334.bak new file mode 100644 index 0000000..aa6048a --- /dev/null +++ b/backend/app.py.payment-layout-fix.20260327-013334.bak @@ -0,0 +1,6682 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.payment-lines-and-rate.20260326-042154.bak b/backend/app.py.payment-lines-and-rate.20260326-042154.bak new file mode 100644 index 0000000..70b8872 --- /dev/null +++ b/backend/app.py.payment-lines-and-rate.20260326-042154.bak @@ -0,0 +1,6642 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.pdf-context-fix.20260326-030343.bak b/backend/app.py.pdf-context-fix.20260326-030343.bak new file mode 100644 index 0000000..54ec377 --- /dev/null +++ b/backend/app.py.pdf-context-fix.20260326-030343.bak @@ -0,0 +1,6610 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the REAL invoice PDF generator instead of stub + """ + from flask import current_app + + with current_app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.pdf-fix.20260326-030059.bak b/backend/app.py.pdf-fix.20260326-030059.bak new file mode 100644 index 0000000..3727061 --- /dev/null +++ b/backend/app.py.pdf-fix.20260326-030059.bak @@ -0,0 +1,6607 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() diff --git a/backend/app.py.pre-circular-import-removal.20260326-050604.bak b/backend/app.py.pre-circular-import-removal.20260326-050604.bak new file mode 100644 index 0000000..39c6616 --- /dev/null +++ b/backend/app.py.pre-circular-import-removal.20260326-050604.bak @@ -0,0 +1,6684 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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() + + +from app import generate_invoice_pdf_bytes + +def send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + explorer_text = "" + try: + invoice_payments = get_invoice_payments(invoice_id) + if invoice_payments: + last_payment = invoice_payments[-1] + txid = last_payment.get("txid") + payment_currency = str(last_payment.get("payment_currency") or "").upper() + explorer_url = None + + if txid: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid}" + + explorer_text = f""" + +Transaction ID: +{txid}""" + if explorer_url: + explorer_text += f""" + +View on explorer: +{explorer_url}""" + except Exception: + pass + + 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')}{explorer_text} + +You can view your invoice anytime in the client portal: +https://portal.outsidethebox.top/portal + +Thank you, +OutsideTheBox +support@outsidethebox.top +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception as e: + print(f"[send_payment_received_email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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() + invoice_payments = get_invoice_payments(invoice_id) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + payment_currency = str(p.get("payment_currency") or "").upper() + txid_value = p.get("txid") + explorer_url = None + + if txid_value: + if "ETHO" in payment_currency: + explorer_url = f"https://etho-exp.outsidethebox.top/tx/{txid_value}" + elif "ETI" in payment_currency or "EGAZ" in payment_currency or "ETICA" in payment_currency: + explorer_url = f"https://explorer.etica-stats.org/tx/{txid_value}" + elif payment_currency == "ETH" or "ETHEREUM" in payment_currency: + explorer_url = f"https://etherscan.io/tx/{txid_value}" + elif "ARB" in payment_currency or "ARBITRUM" in payment_currency: + explorer_url = f"https://arbiscan.io/tx/{txid_value}" + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + pdf.setFont("Helvetica", 9) + + if p.get("received_at_local"): + pdf.drawString(left + 10, y, f"Time: {p.get('received_at_local')}") + y -= 11 + + if txid_value: + tx_line = f"TXID: {txid_value}" + pdf.drawString(left + 10, y, tx_line) + if explorer_url: + pdf.linkURL( + explorer_url, + (left + 10, y - 2, min(right, left + 10 + (len(tx_line) * 5.2)), y + 10), + relative=0 + ) + y -= 11 + elif p.get("reference"): + ref_text = f"Ref: {p.get('reference')}" + for chunk_start in range(0, len(ref_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, ref_text[chunk_start:chunk_start+100]) + y -= 11 + + if p.get("wallet_address"): + wallet_text = f"Wallet: {p.get('wallet_address')}" + for chunk_start in range(0, len(wallet_text), 100): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, wallet_text[chunk_start:chunk_start+100]) + y -= 11 + + try: + crypto_amount = to_decimal(p.get("payment_amount") or "0") + cad_value = to_decimal(p.get("cad_value_at_payment") or "0") + if payment_currency and crypto_amount > 0 and cad_value > 0: + rate_text = f"Rate: 1 {payment_currency} = {(cad_value / crypto_amount):.6f} CAD" + pdf.drawString(left + 10, y, rate_text) + y -= 11 + except Exception: + pass + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception as e: + print(f"[manual payment email] invoice_id={invoice_id} error={type(e).__name__}: {e}") + raise + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + """ + Use the real invoice PDF route through the Flask app object. + Works both in normal runtime and direct shell tests. + """ + with app.app_context(): + with app.test_client() as client: + resp = client.get(f"/invoices/pdf/{invoice_id}") + if resp.status_code != 200: + raise Exception(f"PDF route failed: {resp.status_code}") + return resp.data + diff --git a/backend/app.py.reveal-payment-email-error.20260326-024852.bak b/backend/app.py.reveal-payment-email-error.20260326-024852.bak new file mode 100644 index 0000000..8426d0b --- /dev/null +++ b/backend/app.py.reveal-payment-email-error.20260326-024852.bak @@ -0,0 +1,6603 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 send_payment_received_email(invoice_id, payment_amount_display): + 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 not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + 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 +""" + + pdf_bytes = generate_invoice_pdf_bytes(invoice_id) + attachments = [{ + "filename": f"{invoice_email_row.get('invoice_number')}.pdf", + "mime_type": "application/pdf", + "data": pdf_bytes, + }] + + send_configured_email( + to_email=invoice_email_row.get("email"), + subject=subject, + body=body, + attachments=attachments, + email_type="payment_received", + invoice_id=invoice_id + ) + return True + except Exception: + return False + + +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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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"): + payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" + send_payment_received_email(invoice_id, payment_amount_display) + except Exception: + pass + + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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"): + payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" + send_payment_received_email(invoice["id"], payment_amount_display) + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() diff --git a/backend/update1.sh b/backend/update1.sh new file mode 100755 index 0000000..89d1ecb --- /dev/null +++ b/backend/update1.sh @@ -0,0 +1,51 @@ +cd /home/def/otb_billing || exit 1 +set -e + +NEWVER="v0.5.1" +STAMP="$(date '+%Y-%m-%d %H:%M:%S')" +ZIPNAME="otb_billing-${NEWVER}.zip" + +echo "===== git status =====" +git status --short || true + +echo "===== update VERSION =====" +echo "${NEWVER}" > VERSION + +echo "===== update README.md =====" +cp README.md "README.md.bak.${NEWVER}" + +python3 <<'PY' +from pathlib import Path +from datetime import datetime + +p = Path("README.md") +text = p.read_text() + +entry = f"""## {datetime.now().strftime('%Y-%m-%d')} — v0.5.1 + +- Fixed crypto payment email auto-send failure +- Replaced internal PDF generator call with route-based PDF fetch +- Restored PDF attachments in payment emails +- Improved Payments Applied layout in invoice PDF (multi-line details + rate display) +- Stabilized send_payment_received_email() (removed debug raise, safe failure handling) + +""" + +p.write_text(entry + "\n" + text) +print("OK: README updated") +PY + +echo "===== git add =====" +git add . + +echo "===== git commit =====" +git commit -m "v0.5.1 - crypto email fix, PDF attachment fix, payment layout improvements" + +echo "===== git push =====" +git push + +echo "===== build zip =====" +cd /home/def || exit 1 +zip -r "${ZIPNAME}" otb_billing >/dev/null + +echo "ZIP CREATED: /home/def/${ZIPNAME}" diff --git a/backup_pre_accounting_package_2026-03-09/dashboard.html.bak b/backup_pre_accounting_package_2026-03-09/dashboard.html.bak deleted file mode 100644 index 8f30e9c..0000000 --- a/backup_pre_accounting_package_2026-03-09/dashboard.html.bak +++ /dev/null @@ -1,43 +0,0 @@ - - - -OTB Billing Dashboard - - - - -{% if app_settings.business_logo_url %} -
- -
-{% endif %} -

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

- -

Clients

-

Services

-

Invoices

-

Payments

-

Revenue Report

-

Settings / Config

-

DB Test

- - - - - - - - - - - - - - -
Total ClientsActive ServicesOutstanding InvoicesRevenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ revenue_received|money('CAD') }}
- -

Displayed times are shown in Eastern Time (Toronto).

- -{% include "footer.html" %} - - diff --git a/backup_pre_batch_pdf_export_2026-03-09/app.py.bak b/backup_pre_batch_pdf_export_2026-03-09/app.py.bak deleted file mode 100644 index 47023f8..0000000 --- a/backup_pre_batch_pdf_export_2026-03-09/app.py.bak +++ /dev/null @@ -1,1872 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO, StringIO -import csv -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "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", -} - -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) - - -@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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - - 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) - - query += " ORDER BY i.id ASC" - - 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: - 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") -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() - - 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) - - query += " ORDER BY i.id DESC" - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - } - - return render_template("invoices/list.html", invoices=invoices, filters=filters) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_batch_pdf_export_2026-03-09/invoices_list.html.bak b/backup_pre_batch_pdf_export_2026-03-09/invoices_list.html.bak deleted file mode 100644 index 0f29c2e..0000000 --- a/backup_pre_batch_pdf_export_2026-03-09/invoices_list.html.bak +++ /dev/null @@ -1,143 +0,0 @@ - - - -Invoices - - - - - -

Invoices

- -

Home

-

Create Invoice

- -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- -
- - - - -
-
-
- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} - {{ i.status }} -{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - View | - PDF | - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_batch_print_2026-03-09/app.py.bak b/backup_pre_batch_print_2026-03-09/app.py.bak deleted file mode 100644 index 05e6c44..0000000 --- a/backup_pre_batch_print_2026-03-09/app.py.bak +++ /dev/null @@ -1,2168 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO, StringIO -import csv -import zipfile -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "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", -} - -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) - - -@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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.id, - i.invoice_number, - i.client_id, - c.client_code, - c.company_name, - i.service_id, - i.currency_code, - i.subtotal_amount, - i.tax_amount, - i.total_amount, - i.amount_paid, - i.status, - i.issued_at, - i.due_at, - i.paid_at, - i.notes, - i.created_at, - i.updated_at - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "service_id", - "currency_code", - "subtotal_amount", - "tax_amount", - "total_amount", - "amount_paid", - "status", - "issued_at", - "due_at", - "paid_at", - "notes", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("service_id", ""), - r.get("currency_code", ""), - r.get("subtotal_amount", ""), - r.get("tax_amount", ""), - r.get("total_amount", ""), - r.get("amount_paid", ""), - r.get("status", ""), - r.get("issued_at", ""), - r.get("due_at", ""), - r.get("paid_at", ""), - r.get("notes", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - filename = "invoices" - if start_date or end_date or status or client_id or limit_count: - filename += "_filtered" - filename += ".csv" - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = f"attachment; filename={filename}" - return response - - -@app.route("/invoices/export-pdf.zip") -def export_invoices_pdf_zip(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - def build_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - for invoice in invoices: - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - - filename = "invoices_export" - if start_date: - filename += f"_{start_date}" - if end_date: - filename += f"_to_{end_date}" - if status: - filename += f"_{status}" - if client_id: - filename += f"_client_{client_id}" - if limit_count: - filename += f"_limit_{limit_count}" - filename += ".zip" - - return send_file( - zip_buffer, - mimetype="application/zip", - as_attachment=True, - download_name=filename - ) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id DESC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - ORDER BY company_name ASC - """) - clients = cursor.fetchall() - - conn.close() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_batch_print_2026-03-09/invoices_list.html.bak b/backup_pre_batch_print_2026-03-09/invoices_list.html.bak deleted file mode 100644 index 2a4ac35..0000000 --- a/backup_pre_batch_print_2026-03-09/invoices_list.html.bak +++ /dev/null @@ -1,169 +0,0 @@ - - - -Invoices - - - - - -

Invoices

- -

Home

-

Create Invoice

- -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- - -
- - -
-
- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} - {{ i.status }} -{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - View | - PDF | - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_csv_export_2026-03-09/app.py.bak b/backup_pre_csv_export_2026-03-09/app.py.bak deleted file mode 100644 index 4c9ecd1..0000000 --- a/backup_pre_csv_export_2026-03-09/app.py.bak +++ /dev/null @@ -1,1593 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "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", -} - -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) - - -@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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_csv_export_2026-03-09/clients_list.html.bak b/backup_pre_csv_export_2026-03-09/clients_list.html.bak deleted file mode 100644 index 80f144e..0000000 --- a/backup_pre_csv_export_2026-03-09/clients_list.html.bak +++ /dev/null @@ -1,49 +0,0 @@ - - - -Clients - - - - -

Clients

- -

Home

-

Add Client

- - - - - - - - - - - - - -{% for c in clients %} - - - - - - - - - - -{% endfor %} - -
IDCodeCompanyContactEmailPhoneStatusActions
{{ c.id }}{{ c.client_code }}{{ c.company_name }}{{ c.contact_name }}{{ c.email }}{{ c.phone }}{{ c.status }} - Edit | - - Ledger ({{ c.credit_balance|money('CAD') }}) - -
- -{% include "footer.html" %} - - diff --git a/backup_pre_csv_export_2026-03-09/invoices_list.html.bak b/backup_pre_csv_export_2026-03-09/invoices_list.html.bak deleted file mode 100644 index 6bbdddb..0000000 --- a/backup_pre_csv_export_2026-03-09/invoices_list.html.bak +++ /dev/null @@ -1,80 +0,0 @@ - - - -Invoices - - - - - -

Invoices

- -

Home

-

Create Invoice

- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} - {{ i.status }} -{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - View | - PDF | - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_csv_export_2026-03-09/payments_list.html.bak b/backup_pre_csv_export_2026-03-09/payments_list.html.bak deleted file mode 100644 index 6917328..0000000 --- a/backup_pre_csv_export_2026-03-09/payments_list.html.bak +++ /dev/null @@ -1,102 +0,0 @@ - - - -Payments - - - - -

Payments

- -

Home

-

Record Payment

- - - - - - - - - - - - - - - - - -{% for p in payments %} - - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientMethodCurrencyAmountCAD ValuePayment StatusInvoice StatusRemainingReceivedActions
{{ p.id }}{{ p.invoice_number }}{{ p.client_code }} - {{ p.company_name }}{{ p.payment_method }}{{ p.payment_currency }}{{ p.payment_amount|money(p.payment_currency) }}{{ p.cad_value_at_payment|money('CAD') }}{{ p.payment_status }}{{ p.invoice_status }}{{ (p.total_amount - p.amount_paid)|money(p.invoice_currency_code) }}{{ p.received_at|localtime }} - Edit - {% if p.payment_status == 'confirmed' %} - | -
- -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_email_log_2026-03-09/PROJECT_STATE.md.bak b/backup_pre_email_log_2026-03-09/PROJECT_STATE.md.bak deleted file mode 100644 index a6cf6eb..0000000 --- a/backup_pre_email_log_2026-03-09/PROJECT_STATE.md.bak +++ /dev/null @@ -1,326 +0,0 @@ -# OTB Billing — Project State - -Last Updated: 2026-03-09 -Version: v0.3.1 -Project Path: ~/otb_billing - ---- - -# Project Purpose - -OTB Billing is a contractor-focused billing system designed to be: - -- self-hosted -- portable -- database-backed -- deployable on fresh Linux systems -- suitable for managed hosting or client-installed deployments - -The system is being built as a practical alternative to overly restrictive SaaS billing tools, with emphasis on ownership, simplicity, and contractor workflow. - -Tagline direction: - -By a contractor, for contractors - ---- - -# Current Stack - -Backend: -Flask - -Database: -MariaDB - -PDF Engine: -ReportLab - -Primary Port: -5050 - -Dependencies file: -requirements.txt - ---- - -# Deployment Philosophy - -OTB Billing must remain a deployable product, not just a dev-only app. - -Target install model: - -fresh server -→ installer runs -→ dependencies install -→ MariaDB setup -→ schema setup -→ app launches - -This remains a core project rule. - ---- - -# Current Core Features - -## Clients -- create client -- edit client -- list clients -- status field -- client code support - -## Services -- create service -- edit service -- list services -- service code support -- service status support - -## Invoices -- create invoice -- edit invoice -- list invoices -- automatic invoice numbering -- invoice print view -- invoice PDF download -- invoice lock after payment activity -- invoice statuses -- invoice email sending with PDF attachment -- latest invoice email activity display - -Current invoice statuses: -- draft -- pending -- partial -- paid -- overdue -- cancelled - -## Payments -- record payment -- edit payment -- list payments -- overpayment guard on new payment -- overpayment guard on payment edit -- payment status display -- payment void / reversal workflow -- invoice recalculation after payment changes - -Current payment statuses: -- confirmed -- reversed - -## Credit Ledger -- client credit ledger -- manual credit entries -- client balance color coding -- ledger link visible from client list/edit pages - -## Invoice Rendering -- HTML invoice view -- print-friendly layout -- PDF invoice generation -- client details on invoice -- status badge on invoice -- totals, paid, remaining display -- branding/logo support on HTML and PDF - -## Exports -- clients CSV export -- invoices CSV export -- payments CSV export -- filtered invoice CSV export -- filtered invoice PDF ZIP export -- monthly/quarterly/yearly accounting package ZIP export -- revenue report JSON export - -## Batch / Print -- filtered batch invoice print page -- print-friendly revenue report - -## Reports -- revenue report -- report frequency selector -- JSON report export -- email revenue report JSON - -## Settings / Configuration System -Accessible from: -/settings - -Stored in database table: -app_settings - -### Business Identity Settings -- business name -- business tagline -- business logo URL -- business email -- business phone -- business address -- business website -- business registration number - -### Tax Settings -- tax label -- tax rate -- tax number -- local country -- apply local tax only flag - -### Invoice Behavior Settings -- default currency -- invoice footer -- payment terms -- report frequency - -### SMTP / Email Settings -- SMTP host -- SMTP port -- SMTP username -- SMTP password -- SMTP from email -- SMTP from name -- TLS flag -- SSL flag -- report delivery email - -## Email Logging -Stored in: -email_log - -Tracks: -- email_type -- invoice_id -- recipient_email -- subject -- status -- error_message -- sent_at - -Currently logs: -- invoice email sends -- revenue report email sends -- accounting package email sends - ---- - -# Current Known Good State - -Confirmed working: -- dashboard -- clients -- services -- invoice creation -- auto invoice numbering -- invoice view -- invoice PDF generation -- invoice email with PDF attachment -- invoice email log display -- payment entry -- payment overpayment prevention -- payment reversal / void -- payments list with invoice status and remaining balance -- settings/config page -- business identity shown on invoice view/PDF -- logo display in HTML and PDF -- clients/invoices/payments CSV export -- filtered invoice export -- filtered invoice PDF ZIP export -- batch invoice print -- revenue report -- revenue report JSON export -- revenue report email -- accounting package ZIP export -- accounting package email - ---- - -# Requirements - -Current requirements.txt should include: -- Flask -- mysql-connector-python -- reportlab -- python-dateutil -- pytz - -This file must remain complete so installer-driven deployment works in one shot. - ---- - -# Business / Product Direction - -This system is intended to grow into a deployable billing product for small contractors and related service businesses. - -Target strengths versus typical SaaS billing tools: -- simpler workflow -- data ownership -- exportability -- portability -- contractor-first design -- no hostage-style software design - -Long-term success goal: -build something users are happy to use and proud to own. - ---- - -# Planned Next Features - -## Near-Term -- invoice defaults from settings -- improved tax application logic -- accountant package scheduling / reminders -- client account statement export -- backup/install polish - -## Medium-Term -- quote / estimate system -- recurring invoices -- reminder workflows -- better installer/update flow -- email resend history view - -## Long-Term -- client portal -- role-based access -- accountant/export workflows -- job-tracking integration with related contractor platform modules - ---- - -# Advanced Settings Direction - -Business identity and SMTP belong in settings UI. - -Database credentials should remain installer/config-file driven, not casually editable in standard UI. - -If advanced connection settings are ever exposed in UI, they must be clearly marked as dangerous / advanced and should avoid redisplaying stored passwords. - ---- - -# Repository Discipline - -For this project going forward: -- keep PROJECT_STATE.md updated -- update README.md with version/build notes -- keep requirements.txt complete -- make full ZIP backup on version bumps -- push milestones to git - -Example future archive naming: -otb_billing-v0.3.1.zip - ---- - -# Restart / Run Notes - -Development run method: - -cd ~/otb_billing -python3 backend/app.py - -During active development, run in a visible terminal so logs stay visible. - -Do not rely on hidden/background launch during normal debug workflow. diff --git a/backup_pre_email_log_2026-03-09/app.py.bak b/backup_pre_email_log_2026-03-09/app.py.bak deleted file mode 100644 index 02745b2..0000000 --- a/backup_pre_email_log_2026-03-09/app.py.bak +++ /dev/null @@ -1,2806 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation -from email.message import EmailMessage - -from io import BytesIO, StringIO -import csv -import zipfile -import json -import smtplib -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "report_frequency": "monthly", - "invoice_footer": "", - "payment_terms": "", - "local_country": "Canada", - "apply_local_tax_only": "1", - "smtp_host": "", - "smtp_port": "587", - "smtp_user": "", - "smtp_pass": "", - "smtp_from_email": "", - "smtp_from_name": "", - "smtp_use_tls": "1", - "smtp_use_ssl": "0", - "report_delivery_email": "", -} - -def ensure_app_settings_table(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS app_settings ( - setting_key VARCHAR(100) NOT NULL PRIMARY KEY, - setting_value TEXT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - """) - conn.commit() - conn.close() - -def get_app_settings(): - ensure_app_settings_table() - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT setting_key, setting_value - FROM app_settings - """) - rows = cursor.fetchall() - conn.close() - - settings = dict(APP_SETTINGS_DEFAULTS) - for row in rows: - settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else "" - - return settings - -def save_app_settings(form_data): - ensure_app_settings_table() - conn = get_db_connection() - cursor = conn.cursor() - - for key in APP_SETTINGS_DEFAULTS.keys(): - if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}: - value = "1" if form_data.get(key) else "0" - else: - value = (form_data.get(key) or "").strip() - - cursor.execute(""" - INSERT INTO app_settings (setting_key, setting_value) - VALUES (%s, %s) - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) - """, (key, value)) - - conn.commit() - conn.close() - - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - - - - -def get_report_period_bounds(frequency): - now_local = datetime.now(LOCAL_TZ) - - if frequency == "yearly": - start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) - label = f"{now_local.year}" - elif frequency == "quarterly": - quarter = ((now_local.month - 1) // 3) + 1 - start_month = (quarter - 1) * 3 + 1 - start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0) - label = f"Q{quarter} {now_local.year}" - else: - start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - label = now_local.strftime("%B %Y") - - start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None) - end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None) - - return start_utc, end_utc, label - -def get_revenue_report_data(): - settings = get_app_settings() - frequency = (settings.get("report_frequency") or "monthly").strip().lower() - if frequency not in {"monthly", "quarterly", "yearly"}: - frequency = "monthly" - - start_utc, end_utc, label = get_report_period_bounds(frequency) - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected - FROM payments - WHERE payment_status = 'confirmed' - AND received_at >= %s - AND received_at <= %s - """, (start_utc, end_utc)) - collected_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS invoice_count, - COALESCE(SUM(total_amount), 0) AS invoiced - FROM invoices - WHERE issued_at >= %s - AND issued_at <= %s - """, (start_utc, end_utc)) - invoiced_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS overdue_count, - COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance - FROM invoices - WHERE status = 'overdue' - """) - overdue_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_count, - COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_row = cursor.fetchone() - - conn.close() - - return { - "frequency": frequency, - "period_label": label, - "period_start": start_utc.isoformat(sep=" "), - "period_end": end_utc.isoformat(sep=" "), - "collected_cad": str(to_decimal(collected_row["collected"])), - "invoice_count": int(invoiced_row["invoice_count"] or 0), - "invoiced_total": str(to_decimal(invoiced_row["invoiced"])), - "overdue_count": int(overdue_row["overdue_count"] or 0), - "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])), - "outstanding_count": int(outstanding_row["outstanding_count"] or 0), - "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])), - } - - - -def send_configured_email(to_email, subject, body, attachments=None): - settings = get_app_settings() - - smtp_host = (settings.get("smtp_host") or "").strip() - smtp_port = int((settings.get("smtp_port") or "587").strip() or "587") - smtp_user = (settings.get("smtp_user") or "").strip() - smtp_pass = (settings.get("smtp_pass") or "").strip() - from_email = (settings.get("smtp_from_email") or settings.get("business_email") or "").strip() - from_name = (settings.get("smtp_from_name") or settings.get("business_name") or "").strip() - use_tls = (settings.get("smtp_use_tls") or "0") == "1" - use_ssl = (settings.get("smtp_use_ssl") or "0") == "1" - - if not smtp_host: - raise ValueError("SMTP host is not configured.") - if not from_email: - raise ValueError("From email is not configured.") - if not to_email: - raise ValueError("Recipient email is missing.") - - msg = EmailMessage() - msg["Subject"] = subject - msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email - msg["To"] = to_email - msg.set_content(body) - - for attachment in attachments or []: - filename = attachment["filename"] - mime_type = attachment["mime_type"] - data = attachment["data"] - maintype, subtype = mime_type.split("/", 1) - msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) - - if use_ssl: - with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30) as server: - if smtp_user: - server.login(smtp_user, smtp_pass) - server.send_message(msg) - else: - with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server: - server.ehlo() - if use_tls: - server.starttls() - server.ehlo() - if smtp_user: - server.login(smtp_user, smtp_pass) - server.send_message(msg) - -def build_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - -def build_accounting_package_bytes(): - 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 - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - clients = cursor.fetchall() - - cursor.execute(""" - 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 i.issued_at >= %s - AND i.issued_at <= %s - ORDER BY i.id ASC - """, (start_utc, end_utc)) - invoices_csv_rows = cursor.fetchall() - - 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.issued_at >= %s - AND i.issued_at <= %s - ORDER BY i.id ASC - """, (start_utc, end_utc)) - invoices_pdf_rows = cursor.fetchall() - - 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 - WHERE p.received_at >= %s - AND p.received_at <= %s - ORDER BY p.id ASC - """, (start_utc, end_utc)) - payments = cursor.fetchall() - - conn.close() - - report = get_revenue_report_data() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - clients_io = StringIO() - writer = csv.writer(clients_io) - writer.writerow([ - "id", "client_code", "company_name", "contact_name", "email", "phone", "status", "created_at", "updated_at" - ]) - for r in clients: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - zipf.writestr("csv/clients.csv", clients_io.getvalue()) - - invoices_io = StringIO() - writer = csv.writer(invoices_io) - 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 invoices_csv_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", ""), - ]) - zipf.writestr("csv/invoices.csv", invoices_io.getvalue()) - - payments_io = StringIO() - writer = csv.writer(payments_io) - 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 payments: - 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", ""), - ]) - zipf.writestr("csv/payments.csv", payments_io.getvalue()) - - zipf.writestr("json/revenue_report.json", json.dumps(report, indent=2)) - - for invoice in invoices_pdf_rows: - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"pdf/{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - safe_label = label.replace(" ", "_") - filename = f"accounting_package_{safe_label}.zip" - return zip_buffer.getvalue(), filename - - - - -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(50) 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() - -@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, - 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() - - recipient = (invoice.get("email") or "").strip() - if not recipient: - return "Client email is missing for this invoice.", 400 - - settings = get_app_settings() - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - - subject = f"Invoice {invoice['invoice_number']} from {settings.get('business_name') or 'OTB Billing'}" - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - 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, - attachments=[{ - "filename": f"{invoice['invoice_number']}.pdf", - "mime_type": "application/pdf", - "data": pdf_bytes, - }] - ) - log_email_event("invoice", recipient, subject, "sent", invoice_id=invoice_id, error_message=None) - return redirect(f"/invoices/view/{invoice_id}?email_sent=1") - except Exception as e: - log_email_event("invoice", recipient, subject, "failed", invoice_id=invoice_id, error_message=str(e)) - 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 - - report = get_revenue_report_data() - report_json = json.dumps(report, indent=2).encode("utf-8") - - 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, - attachments=[{ - "filename": "revenue_report.json", - "mime_type": "application/json", - "data": report_json, - }] - ) - log_email_event("revenue_report", recipient, subject, "sent", invoice_id=None, error_message=None) - return redirect("/reports/revenue?email_sent=1") - except Exception as e: - log_email_event("revenue_report", recipient, subject, "failed", invoice_id=None, error_message=str(e)) - 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 - - package_bytes, filename = build_accounting_package_bytes() - - subject = f"Accounting Package from {settings.get('business_name') or 'OTB Billing'}" - body = "Attached is the latest accounting package export." - - try: - send_configured_email( - recipient, - subject, - body, - attachments=[{ - "filename": filename, - "mime_type": "application/zip", - "data": package_bytes, - }] - ) - log_email_event("accounting_package", recipient, subject, "sent", invoice_id=None, error_message=None) - return redirect("/?pkg_email=1") - except Exception as e: - log_email_event("accounting_package", recipient, subject, "failed", invoice_id=None, error_message=str(e)) - return redirect("/?pkg_email_failed=1") - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.id, - i.invoice_number, - i.client_id, - c.client_code, - c.company_name, - i.service_id, - i.currency_code, - i.subtotal_amount, - i.tax_amount, - i.total_amount, - i.amount_paid, - i.status, - i.issued_at, - i.due_at, - i.paid_at, - i.notes, - i.created_at, - i.updated_at - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "service_id", - "currency_code", - "subtotal_amount", - "tax_amount", - "total_amount", - "amount_paid", - "status", - "issued_at", - "due_at", - "paid_at", - "notes", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("service_id", ""), - r.get("currency_code", ""), - r.get("subtotal_amount", ""), - r.get("tax_amount", ""), - r.get("total_amount", ""), - r.get("amount_paid", ""), - r.get("status", ""), - r.get("issued_at", ""), - r.get("due_at", ""), - r.get("paid_at", ""), - r.get("notes", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - filename = "invoices" - if start_date or end_date or status or client_id or limit_count: - filename += "_filtered" - filename += ".csv" - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = f"attachment; filename={filename}" - return response - - -@app.route("/invoices/export-pdf.zip") -def export_invoices_pdf_zip(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - def build_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - for invoice in invoices: - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - - filename = "invoices_export" - if start_date: - filename += f"_{start_date}" - if end_date: - filename += f"_to_{end_date}" - if status: - filename += f"_{status}" - if client_id: - filename += f"_client_{client_id}" - if limit_count: - filename += f"_limit_{limit_count}" - filename += ".zip" - - return send_file( - zip_buffer, - mimetype="application/zip", - as_attachment=True, - download_name=filename - ) - - -@app.route("/invoices/print") -def print_invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id DESC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - ORDER BY company_name ASC - """) - clients = cursor.fetchall() - - conn.close() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - - return send_file( - BytesIO(pdf_bytes), - 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 - - ensure_email_log_table() - cursor.execute(""" - SELECT * - FROM email_log - WHERE invoice_id = %s - AND email_type = 'invoice' - ORDER BY sent_at DESC, id DESC - LIMIT 1 - """, (invoice_id,)) - latest_email_log = cursor.fetchone() - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings, latest_email_log=latest_email_log) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_email_send_2026-03-09/app.py.bak b/backup_pre_email_send_2026-03-09/app.py.bak deleted file mode 100644 index 840bb2c..0000000 --- a/backup_pre_email_send_2026-03-09/app.py.bak +++ /dev/null @@ -1,2719 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO, StringIO -import csv -import zipfile -import json -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "report_frequency": "monthly", - "invoice_footer": "", - "payment_terms": "", - "local_country": "Canada", - "apply_local_tax_only": "1", - "smtp_host": "", - "smtp_port": "587", - "smtp_user": "", - "smtp_pass": "", - "smtp_from_email": "", - "smtp_from_name": "", - "smtp_use_tls": "1", - "smtp_use_ssl": "0", -} - -def ensure_app_settings_table(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS app_settings ( - setting_key VARCHAR(100) NOT NULL PRIMARY KEY, - setting_value TEXT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - """) - conn.commit() - conn.close() - -def get_app_settings(): - ensure_app_settings_table() - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT setting_key, setting_value - FROM app_settings - """) - rows = cursor.fetchall() - conn.close() - - settings = dict(APP_SETTINGS_DEFAULTS) - for row in rows: - settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else "" - - return settings - -def save_app_settings(form_data): - ensure_app_settings_table() - conn = get_db_connection() - cursor = conn.cursor() - - for key in APP_SETTINGS_DEFAULTS.keys(): - if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}: - value = "1" if form_data.get(key) else "0" - else: - value = (form_data.get(key) or "").strip() - - cursor.execute(""" - INSERT INTO app_settings (setting_key, setting_value) - VALUES (%s, %s) - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) - """, (key, value)) - - conn.commit() - conn.close() - - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - - - - -def get_report_period_bounds(frequency): - now_local = datetime.now(LOCAL_TZ) - - if frequency == "yearly": - start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) - label = f"{now_local.year}" - elif frequency == "quarterly": - quarter = ((now_local.month - 1) // 3) + 1 - start_month = (quarter - 1) * 3 + 1 - start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0) - label = f"Q{quarter} {now_local.year}" - else: - start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - label = now_local.strftime("%B %Y") - - start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None) - end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None) - - return start_utc, end_utc, label - -def get_revenue_report_data(): - settings = get_app_settings() - frequency = (settings.get("report_frequency") or "monthly").strip().lower() - if frequency not in {"monthly", "quarterly", "yearly"}: - frequency = "monthly" - - start_utc, end_utc, label = get_report_period_bounds(frequency) - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected - FROM payments - WHERE payment_status = 'confirmed' - AND received_at >= %s - AND received_at <= %s - """, (start_utc, end_utc)) - collected_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS invoice_count, - COALESCE(SUM(total_amount), 0) AS invoiced - FROM invoices - WHERE issued_at >= %s - AND issued_at <= %s - """, (start_utc, end_utc)) - invoiced_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS overdue_count, - COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance - FROM invoices - WHERE status = 'overdue' - """) - overdue_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_count, - COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_row = cursor.fetchone() - - conn.close() - - return { - "frequency": frequency, - "period_label": label, - "period_start": start_utc.isoformat(sep=" "), - "period_end": end_utc.isoformat(sep=" "), - "collected_cad": str(to_decimal(collected_row["collected"])), - "invoice_count": int(invoiced_row["invoice_count"] or 0), - "invoiced_total": str(to_decimal(invoiced_row["invoiced"])), - "overdue_count": int(overdue_row["overdue_count"] or 0), - "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])), - "outstanding_count": int(outstanding_row["outstanding_count"] or 0), - "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])), - } - -@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(): - 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 - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - clients = cursor.fetchall() - - cursor.execute(""" - 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 i.issued_at >= %s - AND i.issued_at <= %s - ORDER BY i.id ASC - """, (start_utc, end_utc)) - invoices_csv_rows = cursor.fetchall() - - 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.issued_at >= %s - AND i.issued_at <= %s - ORDER BY i.id ASC - """, (start_utc, end_utc)) - invoices_pdf_rows = cursor.fetchall() - - 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 - WHERE p.received_at >= %s - AND p.received_at <= %s - ORDER BY p.id ASC - """, (start_utc, end_utc)) - payments = cursor.fetchall() - - conn.close() - - report = get_revenue_report_data() - - def render_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - # clients.csv - clients_io = StringIO() - writer = csv.writer(clients_io) - writer.writerow([ - "id", "client_code", "company_name", "contact_name", "email", "phone", "status", "created_at", "updated_at" - ]) - for r in clients: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - zipf.writestr("csv/clients.csv", clients_io.getvalue()) - - # invoices.csv - invoices_io = StringIO() - writer = csv.writer(invoices_io) - 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 invoices_csv_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", ""), - ]) - zipf.writestr("csv/invoices.csv", invoices_io.getvalue()) - - # payments.csv - payments_io = StringIO() - writer = csv.writer(payments_io) - 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 payments: - 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", ""), - ]) - zipf.writestr("csv/payments.csv", payments_io.getvalue()) - - # revenue report json - zipf.writestr("json/revenue_report.json", json.dumps(report, indent=2)) - - # invoice PDFs - for invoice in invoices_pdf_rows: - pdf_bytes = render_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"pdf/{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - - safe_label = label.replace(" ", "_") - filename = f"accounting_package_{safe_label}.zip" - - return send_file( - zip_buffer, - 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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.id, - i.invoice_number, - i.client_id, - c.client_code, - c.company_name, - i.service_id, - i.currency_code, - i.subtotal_amount, - i.tax_amount, - i.total_amount, - i.amount_paid, - i.status, - i.issued_at, - i.due_at, - i.paid_at, - i.notes, - i.created_at, - i.updated_at - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "service_id", - "currency_code", - "subtotal_amount", - "tax_amount", - "total_amount", - "amount_paid", - "status", - "issued_at", - "due_at", - "paid_at", - "notes", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("service_id", ""), - r.get("currency_code", ""), - r.get("subtotal_amount", ""), - r.get("tax_amount", ""), - r.get("total_amount", ""), - r.get("amount_paid", ""), - r.get("status", ""), - r.get("issued_at", ""), - r.get("due_at", ""), - r.get("paid_at", ""), - r.get("notes", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - filename = "invoices" - if start_date or end_date or status or client_id or limit_count: - filename += "_filtered" - filename += ".csv" - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = f"attachment; filename={filename}" - return response - - -@app.route("/invoices/export-pdf.zip") -def export_invoices_pdf_zip(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - def build_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - for invoice in invoices: - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - - filename = "invoices_export" - if start_date: - filename += f"_{start_date}" - if end_date: - filename += f"_to_{end_date}" - if status: - filename += f"_{status}" - if client_id: - filename += f"_client_{client_id}" - if limit_count: - filename += f"_limit_{limit_count}" - filename += ".zip" - - return send_file( - zip_buffer, - mimetype="application/zip", - as_attachment=True, - download_name=filename - ) - - -@app.route("/invoices/print") -def print_invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id DESC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - ORDER BY company_name ASC - """) - clients = cursor.fetchall() - - conn.close() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_email_send_2026-03-09/dashboard.html.bak b/backup_pre_email_send_2026-03-09/dashboard.html.bak deleted file mode 100644 index 15657de..0000000 --- a/backup_pre_email_send_2026-03-09/dashboard.html.bak +++ /dev/null @@ -1,44 +0,0 @@ - - - -OTB Billing Dashboard - - - - -{% if app_settings.business_logo_url %} -
- -
-{% endif %} -

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

- -

Clients

-

Services

-

Invoices

-

Payments

-

Revenue Report

-

Monthly Accounting Package

-

Settings / Config

-

DB Test

- - - - - - - - - - - - - - -
Total ClientsActive ServicesOutstanding InvoicesRevenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ revenue_received|money('CAD') }}
- -

Displayed times are shown in Eastern Time (Toronto).

- -{% include "footer.html" %} - - diff --git a/backup_pre_email_send_2026-03-09/invoices_view.html.bak b/backup_pre_email_send_2026-03-09/invoices_view.html.bak deleted file mode 100644 index f401b2f..0000000 --- a/backup_pre_email_send_2026-03-09/invoices_view.html.bak +++ /dev/null @@ -1,207 +0,0 @@ - - - -Invoice {{ invoice.invoice_number }} - - - - -
- - -
- -{% if settings.business_logo_url %} - -{% endif %} - -
-

Invoice {{ invoice.invoice_number }}

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

Bill To

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

Invoice Details

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

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

- {{ settings.invoice_footer }} -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_email_send_2026-03-09/revenue.html.bak b/backup_pre_email_send_2026-03-09/revenue.html.bak deleted file mode 100644 index e60c148..0000000 --- a/backup_pre_email_send_2026-03-09/revenue.html.bak +++ /dev/null @@ -1,73 +0,0 @@ - - - -Revenue Report - - - - -

Revenue Report

- -

Home

- - - -

-Frequency: {{ report.frequency }}
-Period: {{ report.period_label }} -

- -
-
-

Collected (CAD)

-
{{ report.collected_cad|money('CAD') }}
-
- -
-

Invoices Issued

-
{{ report.invoice_count }}
-
{{ report.invoiced_total|money('CAD') }} CAD total
-
- -
-

Outstanding Invoices

-
{{ report.outstanding_count }}
-
{{ report.outstanding_balance|money('CAD') }} CAD outstanding
-
- -
-

Overdue Invoices

-
{{ report.overdue_count }}
-
{{ report.overdue_balance|money('CAD') }} CAD overdue
-
-
- -{% include "footer.html" %} - - diff --git a/backup_pre_email_send_2026-03-09/settings.html.bak b/backup_pre_email_send_2026-03-09/settings.html.bak deleted file mode 100644 index 272257c..0000000 --- a/backup_pre_email_send_2026-03-09/settings.html.bak +++ /dev/null @@ -1,199 +0,0 @@ - - - -Settings - - - - -

Settings / Config

- -

Home

- -
-
-
-

Business Identity

- - Business Name
-
- - Business Logo URL
-
- Example: /static/favicon.png or https://site.com/logo.png
- - {% if settings.business_logo_url %} -
- Business Logo Preview -
- {% endif %} - - Slogan / Tagline
-
- - Business Email
-
- - Business Phone
-
- - Business Address
-
- - Website
-
- - Business Number / Registration Number
-
- - Default Currency
- - - Report Frequency
- -
- -
-

Tax Settings

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

Advanced / Email / SMTP

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

Notes

-

- Branding, tax identity, and SMTP values are stored here for this installation. -

-

- Logo can be a local static path like /static/favicon.png or a full external/IPFS URL. -

-

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

-
-
- -
- -
-
- -{% include "footer.html" %} - - diff --git a/backup_pre_invoice_numbering_2026-03-09/app.py.bak b/backup_pre_invoice_numbering_2026-03-09/app.py.bak deleted file mode 100644 index 1d3ed88..0000000 --- a/backup_pre_invoice_numbering_2026-03-09/app.py.bak +++ /dev/null @@ -1,1437 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/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() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def line(text, x=left, y_pos=None, font="Helvetica", size=11): - nonlocal y - if y_pos is not None: - y = y_pos - pdf.setFont(font, size) - pdf.drawString(x, y, str(text) if text 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']}") - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left, y, f"Invoice {invoice['invoice_number']}") - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, "By a contractor, for contractors") - y -= 30 - - 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 - - # Bill To - 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 - - # Invoice details - 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 - - # Service table headers - 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 - totals_x_label = 360 - totals_x_value = right - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Subtotal") - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Tax") - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Total") - pdf.drawRightString(totals_x_value, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Paid") - pdf.drawRightString(totals_x_value, y, money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))) - y -= 18 - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - 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 -= 30 - - if invoice.get("notes"): - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Notes") - y -= 18 - pdf.setFont("Helvetica", 11) - notes = str(invoice["notes"]) - for chunk_start in range(0, len(notes), 90): - pdf.drawString(left, y, notes[chunk_start:chunk_start+90]) - y -= 14 - if y < 60: - pdf.showPage() - y = height - 50 - pdf.setFont("Helvetica", 11) - - 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() - return render_template("invoices/view.html", invoice=invoice) -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_invoice_numbering_2026-03-09/invoices_new.html.bak b/backup_pre_invoice_numbering_2026-03-09/invoices_new.html.bak deleted file mode 100644 index c97f50d..0000000 --- a/backup_pre_invoice_numbering_2026-03-09/invoices_new.html.bak +++ /dev/null @@ -1,81 +0,0 @@ - - - -New Invoice - - - - -

Create Invoice

- -{% if errors %} -
- Please fix the following: -
    - {% for error in errors %} -
  • {{ error }}
  • - {% endfor %} -
-
-{% endif %} - -
- -

-Client *
- -

- -

-Service *
- -

- -

-Currency *
- -

- -

-Total Amount *
- -

- -

-Due Date *
- -

- -

-Notes
- -

- -

- -

- -
- - - -{% include "footer.html" %} diff --git a/backup_pre_invoice_numbering_2026-03-09/requirements.txt.bak b/backup_pre_invoice_numbering_2026-03-09/requirements.txt.bak deleted file mode 100644 index 0e458d8..0000000 --- a/backup_pre_invoice_numbering_2026-03-09/requirements.txt.bak +++ /dev/null @@ -1,5 +0,0 @@ -Flask -mysql-connector-python -reportlab -python-dateutil -pytz diff --git a/backup_pre_invoice_pdf_2026-03-09/app.py.bak b/backup_pre_invoice_pdf_2026-03-09/app.py.bak deleted file mode 100644 index 59d0d3f..0000000 --- a/backup_pre_invoice_pdf_2026-03-09/app.py.bak +++ /dev/null @@ -1,1258 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - -@app.route("/invoices/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() - return render_template("invoices/view.html", invoice=invoice) -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_invoice_pdf_2026-03-09/invoices_list.html.bak b/backup_pre_invoice_pdf_2026-03-09/invoices_list.html.bak deleted file mode 100644 index 508a023..0000000 --- a/backup_pre_invoice_pdf_2026-03-09/invoices_list.html.bak +++ /dev/null @@ -1,79 +0,0 @@ - - - -Invoices - - - - - -

Invoices

- -

Home

-

Create Invoice

- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} - {{ i.status }} -{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - View | - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_invoice_pdf_2026-03-09/invoices_view.html.bak b/backup_pre_invoice_pdf_2026-03-09/invoices_view.html.bak deleted file mode 100644 index e37ec6e..0000000 --- a/backup_pre_invoice_pdf_2026-03-09/invoices_view.html.bak +++ /dev/null @@ -1,187 +0,0 @@ - - - -Invoice {{ invoice.invoice_number }} - - - - -
- - -
-
-

Invoice {{ invoice.invoice_number }}

- {{ invoice.status }} -
-
- OTB Billing
- By a contractor, for contractors -
-
- -
-
-

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 }} -
-
- - - - - - - - - - - - - - -
Service CodeServiceDescriptionTotal
{{ invoice.service_code or '-' }}{{ invoice.service_name or '-' }}{{ invoice.notes or '-' }}{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
- - - - - - - - - - - - - - - - - - - - - - -
Subtotal{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
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 invoice.notes %} -
- Notes

- {{ invoice.notes }} -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_invoice_print_view_2026-03-09/app.py.bak b/backup_pre_invoice_print_view_2026-03-09/app.py.bak deleted file mode 100644 index 970b1c6..0000000 --- a/backup_pre_invoice_print_view_2026-03-09/app.py.bak +++ /dev/null @@ -1,1228 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_invoice_print_view_2026-03-09/invoices_list.html.bak b/backup_pre_invoice_print_view_2026-03-09/invoices_list.html.bak deleted file mode 100644 index 3966c5e..0000000 --- a/backup_pre_invoice_print_view_2026-03-09/invoices_list.html.bak +++ /dev/null @@ -1,78 +0,0 @@ - - - -Invoices - - - - - -

Invoices

- -

Home

-

Create Invoice

- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} - {{ i.status }} -{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_invoice_range_export_2026-03-09/app.py.bak b/backup_pre_invoice_range_export_2026-03-09/app.py.bak deleted file mode 100644 index 4c6b45d..0000000 --- a/backup_pre_invoice_range_export_2026-03-09/app.py.bak +++ /dev/null @@ -1,1813 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO, StringIO -import csv -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "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", -} - -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) - - -@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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - 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 - ORDER BY i.id ASC - """) - 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", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=invoices.csv" - return response - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_invoice_range_export_2026-03-09/invoices_list.html.bak b/backup_pre_invoice_range_export_2026-03-09/invoices_list.html.bak deleted file mode 100644 index ba7122c..0000000 --- a/backup_pre_invoice_range_export_2026-03-09/invoices_list.html.bak +++ /dev/null @@ -1,81 +0,0 @@ - - - -Invoices - - - - - -

Invoices

- -

Home

-

Create Invoice

-

Export CSV

- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} - {{ i.status }} -{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - View | - PDF | - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_new_payment_rebuild_2026-03-08/app.py.bak b/backup_pre_new_payment_rebuild_2026-03-08/app.py.bak deleted file mode 100644 index 6b71bfb..0000000 --- a/backup_pre_new_payment_rebuild_2026-03-08/app.py.bak +++ /dev/null @@ -1,1149 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - amount_value = None - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT client_id FROM invoices WHERE id = %s", (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - client_id = invoice["client_id"] - - 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, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_overpayment_guard_2026-03-08/app.py.bak b/backup_pre_overpayment_guard_2026-03-08/app.py.bak deleted file mode 100644 index 6b71bfb..0000000 --- a/backup_pre_overpayment_guard_2026-03-08/app.py.bak +++ /dev/null @@ -1,1149 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - amount_value = None - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT client_id FROM invoices WHERE id = %s", (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - client_id = invoice["client_id"] - - 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, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_payment_filter_2026-03-08/app.py.bak b/backup_pre_payment_filter_2026-03-08/app.py.bak deleted file mode 100644 index 6b71bfb..0000000 --- a/backup_pre_payment_filter_2026-03-08/app.py.bak +++ /dev/null @@ -1,1149 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - amount_value = None - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT client_id FROM invoices WHERE id = %s", (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - client_id = invoice["client_id"] - - 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, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_payment_filter_2026-03-08/payments_new.html.bak b/backup_pre_payment_filter_2026-03-08/payments_new.html.bak deleted file mode 100644 index 3e6cd06..0000000 --- a/backup_pre_payment_filter_2026-03-08/payments_new.html.bak +++ /dev/null @@ -1,103 +0,0 @@ - - - -New Payment - - - -

Record Payment

- -{% if errors %} -
- Please fix the following: -
    - {% for error in errors %} -
  • {{ error }}
  • - {% endfor %} -
-
-{% endif %} - -
- -

-Invoice *
- -

- -

-Payment Method *
- -

- -

-Payment Currency *
- -

- -

-Payment Amount *
- -

- -

-CAD Value At Payment *
- -

- -

-Reference
- -

- -

-Sender Name
- -

- -

-TXID
- -

- -

-Wallet Address
- -

- -

-Notes
- -

- -

- -

- -
- - - -{% include "footer.html" %} diff --git a/backup_pre_payment_policy_guard_2026-03-08/app.py.bak b/backup_pre_payment_policy_guard_2026-03-08/app.py.bak deleted file mode 100644 index 271c1b0..0000000 --- a/backup_pre_payment_policy_guard_2026-03-08/app.py.bak +++ /dev/null @@ -1,1184 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_payment_policy_guard_2026-03-08/payments_edit.html.bak b/backup_pre_payment_policy_guard_2026-03-08/payments_edit.html.bak deleted file mode 100644 index 0c80a68..0000000 --- a/backup_pre_payment_policy_guard_2026-03-08/payments_edit.html.bak +++ /dev/null @@ -1,107 +0,0 @@ - - - -Edit Payment - - - -

Edit Payment

- -

Home

-

Back to Payments

- -{% if errors %} -
- Please fix the following: -
    - {% for error in errors %} -
  • {{ error }}
  • - {% endfor %} -
-
-{% endif %} - -
- -

-Payment ID
- -

- -

-Invoice
- -

- -

-Received
- -

- -

-Payment Method *
- -

- -

-Payment Currency *
- -

- -

-Payment Amount *
- -

- -

-CAD Value At Payment *
- -

- -

-Reference
- -

- -

-Sender Name
- -

- -

-TXID
- -

- -

-Wallet Address
- -

- -

-Notes
- -

- -

- -

- -
- -{% include "footer.html" %} - - diff --git a/backup_pre_payment_policy_guard_2026-03-08/payments_new.html.bak b/backup_pre_payment_policy_guard_2026-03-08/payments_new.html.bak deleted file mode 100644 index 786b59d..0000000 --- a/backup_pre_payment_policy_guard_2026-03-08/payments_new.html.bak +++ /dev/null @@ -1,139 +0,0 @@ - - - -New Payment - - - - -

Record Payment

- -

Home

-

Back to Payments

- -
- Only invoices with an outstanding balance are shown here.
- Paid and cancelled invoices are excluded from payment entry. -
- -{% if errors %} -
- Please fix the following: -
    - {% for error in errors %} -
  • {{ error }}
  • - {% endfor %} -
-
-{% endif %} - -
- -

-Invoice *
- -

- -

-Payment Method *
- -

- -

-Payment Currency *
- -

- -

-Payment Amount *
- -

- -

-CAD Value At Payment *
- -

- -

-Reference
- -

- -

-Sender Name
- -

- -

-TXID
- -

- -

-Wallet Address
- -

- -

-Notes
- -

- -

- -

- -
- -{% include "footer.html" %} - - diff --git a/backup_pre_payment_void_2026-03-08/app.py.bak b/backup_pre_payment_void_2026-03-08/app.py.bak deleted file mode 100644 index 271c1b0..0000000 --- a/backup_pre_payment_void_2026-03-08/app.py.bak +++ /dev/null @@ -1,1184 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_payment_void_2026-03-08/payments_list.html.bak b/backup_pre_payment_void_2026-03-08/payments_list.html.bak deleted file mode 100644 index 2847cc7..0000000 --- a/backup_pre_payment_void_2026-03-08/payments_list.html.bak +++ /dev/null @@ -1,100 +0,0 @@ - - - -Payments - - - - -

Payments

- -

Home

-

Record Payment

- - - - - - - - - - - - - - - - -{% for p in payments %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientMethodCurrencyAmountCAD ValuePayment StatusInvoice StatusReceivedActions
{{ p.id }}{{ p.invoice_number }}{{ p.client_code }} - {{ p.company_name }}{{ p.payment_method }}{{ p.payment_currency }}{{ p.payment_amount|money(p.payment_currency) }}{{ p.cad_value_at_payment|money('CAD') }}{{ p.payment_status }}{{ p.invoice_status }}{{ p.received_at|localtime }} - Edit - {% if p.payment_status == 'confirmed' %} - | -
- -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_payments_list_cleanup_2026-03-08/app.py.bak b/backup_pre_payments_list_cleanup_2026-03-08/app.py.bak deleted file mode 100644 index 187be3f..0000000 --- a/backup_pre_payments_list_cleanup_2026-03-08/app.py.bak +++ /dev/null @@ -1,1224 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_payments_list_cleanup_2026-03-08/payments_list.html.bak b/backup_pre_payments_list_cleanup_2026-03-08/payments_list.html.bak deleted file mode 100644 index 2847cc7..0000000 --- a/backup_pre_payments_list_cleanup_2026-03-08/payments_list.html.bak +++ /dev/null @@ -1,100 +0,0 @@ - - - -Payments - - - - -

Payments

- -

Home

-

Record Payment

- - - - - - - - - - - - - - - - -{% for p in payments %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientMethodCurrencyAmountCAD ValuePayment StatusInvoice StatusReceivedActions
{{ p.id }}{{ p.invoice_number }}{{ p.client_code }} - {{ p.company_name }}{{ p.payment_method }}{{ p.payment_currency }}{{ p.payment_amount|money(p.payment_currency) }}{{ p.cad_value_at_payment|money('CAD') }}{{ p.payment_status }}{{ p.invoice_status }}{{ p.received_at|localtime }} - Edit - {% if p.payment_status == 'confirmed' %} - | -
- -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_pdf_logo_2026-03-09/app.py.bak b/backup_pre_pdf_logo_2026-03-09/app.py.bak deleted file mode 100644 index 363ca6f..0000000 --- a/backup_pre_pdf_logo_2026-03-09/app.py.bak +++ /dev/null @@ -1,1584 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "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", -} - -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) - - -@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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - 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']}") - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left, 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") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_revenue_report_json_2026-03-09/app.py.bak b/backup_pre_revenue_report_json_2026-03-09/app.py.bak deleted file mode 100644 index fa73fca..0000000 --- a/backup_pre_revenue_report_json_2026-03-09/app.py.bak +++ /dev/null @@ -1,2242 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO, StringIO -import csv -import zipfile -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "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", -} - -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) - - -@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("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.id, - i.invoice_number, - i.client_id, - c.client_code, - c.company_name, - i.service_id, - i.currency_code, - i.subtotal_amount, - i.tax_amount, - i.total_amount, - i.amount_paid, - i.status, - i.issued_at, - i.due_at, - i.paid_at, - i.notes, - i.created_at, - i.updated_at - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "service_id", - "currency_code", - "subtotal_amount", - "tax_amount", - "total_amount", - "amount_paid", - "status", - "issued_at", - "due_at", - "paid_at", - "notes", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("service_id", ""), - r.get("currency_code", ""), - r.get("subtotal_amount", ""), - r.get("tax_amount", ""), - r.get("total_amount", ""), - r.get("amount_paid", ""), - r.get("status", ""), - r.get("issued_at", ""), - r.get("due_at", ""), - r.get("paid_at", ""), - r.get("notes", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - filename = "invoices" - if start_date or end_date or status or client_id or limit_count: - filename += "_filtered" - filename += ".csv" - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = f"attachment; filename={filename}" - return response - - -@app.route("/invoices/export-pdf.zip") -def export_invoices_pdf_zip(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - def build_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - for invoice in invoices: - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - - filename = "invoices_export" - if start_date: - filename += f"_{start_date}" - if end_date: - filename += f"_to_{end_date}" - if status: - filename += f"_{status}" - if client_id: - filename += f"_client_{client_id}" - if limit_count: - filename += f"_limit_{limit_count}" - filename += ".zip" - - return send_file( - zip_buffer, - mimetype="application/zip", - as_attachment=True, - download_name=filename - ) - - -@app.route("/invoices/print") -def print_invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id DESC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - ORDER BY company_name ASC - """) - clients = cursor.fetchall() - - conn.close() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_revenue_report_json_2026-03-09/dashboard.html.bak b/backup_pre_revenue_report_json_2026-03-09/dashboard.html.bak deleted file mode 100644 index 9be8f84..0000000 --- a/backup_pre_revenue_report_json_2026-03-09/dashboard.html.bak +++ /dev/null @@ -1,42 +0,0 @@ - - - -OTB Billing Dashboard - - - - -{% if app_settings.business_logo_url %} -
- -
-{% endif %} -

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

- -

Clients

-

Services

-

Invoices

-

Payments

-

Settings / Config

-

DB Test

- - - - - - - - - - - - - - -
Total ClientsActive ServicesOutstanding InvoicesRevenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ revenue_received|money('CAD') }}
- -

Displayed times are shown in Eastern Time (Toronto).

- -{% include "footer.html" %} - - diff --git a/backup_pre_revenue_report_json_2026-03-09/settings.html.bak b/backup_pre_revenue_report_json_2026-03-09/settings.html.bak deleted file mode 100644 index 20c1093..0000000 --- a/backup_pre_revenue_report_json_2026-03-09/settings.html.bak +++ /dev/null @@ -1,192 +0,0 @@ - - - -Settings - - - - -

Settings / Config

- -

Home

- -
-
-
-

Business Identity

- - Business Name
-
- - Business Logo URL
-
- Example: /static/favicon.png or https://site.com/logo.png
- - {% if settings.business_logo_url %} -
- Business Logo Preview -
- {% endif %} - - Slogan / Tagline
-
- - Business Email
-
- - Business Phone
-
- - Business Address
-
- - Website
-
- - Business Number / Registration Number
-
- - Default Currency
- -
- -
-

Tax Settings

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

Advanced / Email / SMTP

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

Notes

-

- Branding, tax identity, and SMTP values are stored here for this installation. -

-

- Logo can be a local static path like /static/favicon.png or a full external/IPFS URL. -

-

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

-
-
- -
- -
-
- -{% include "footer.html" %} - - diff --git a/backup_pre_settings_config_2026-03-09/app.py.bak b/backup_pre_settings_config_2026-03-09/app.py.bak deleted file mode 100644 index f357c2d..0000000 --- a/backup_pre_settings_config_2026-03-09/app.py.bak +++ /dev/null @@ -1,1462 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - 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() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def line(text, x=left, y_pos=None, font="Helvetica", size=11): - nonlocal y - if y_pos is not None: - y = y_pos - pdf.setFont(font, size) - pdf.drawString(x, y, str(text) if text 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']}") - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left, y, f"Invoice {invoice['invoice_number']}") - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, "By a contractor, for contractors") - y -= 30 - - 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 - - # Bill To - 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 - - # Invoice details - 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 - - # Service table headers - 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 - totals_x_label = 360 - totals_x_value = right - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Subtotal") - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Tax") - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Total") - pdf.drawRightString(totals_x_value, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Paid") - pdf.drawRightString(totals_x_value, y, money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))) - y -= 18 - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - 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 -= 30 - - if invoice.get("notes"): - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Notes") - y -= 18 - pdf.setFont("Helvetica", 11) - notes = str(invoice["notes"]) - for chunk_start in range(0, len(notes), 90): - pdf.drawString(left, y, notes[chunk_start:chunk_start+90]) - y -= 14 - if y < 60: - pdf.showPage() - y = height - 50 - pdf.setFont("Helvetica", 11) - - 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() - return render_template("invoices/view.html", invoice=invoice) -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_settings_config_2026-03-09/dashboard.html.bak b/backup_pre_settings_config_2026-03-09/dashboard.html.bak deleted file mode 100644 index 87908a5..0000000 --- a/backup_pre_settings_config_2026-03-09/dashboard.html.bak +++ /dev/null @@ -1,35 +0,0 @@ - - - -OTB Billing Dashboard - - - -

OTB Billing Dashboard

- -

Clients

-

Services

-

Invoices

-

Payments

-

DB Test

- - - - - - - - - - - - - - -
Total ClientsActive ServicesOutstanding InvoicesRevenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ revenue_received|money('CAD') }}
- -

Displayed times are shown in Eastern Time (Toronto).

- -{% include "footer.html" %} - - diff --git a/backup_pre_settings_config_2026-03-09/invoices_view.html.bak b/backup_pre_settings_config_2026-03-09/invoices_view.html.bak deleted file mode 100644 index b29709f..0000000 --- a/backup_pre_settings_config_2026-03-09/invoices_view.html.bak +++ /dev/null @@ -1,188 +0,0 @@ - - - -Invoice {{ invoice.invoice_number }} - - - - -
- - -
-
-

Invoice {{ invoice.invoice_number }}

- {{ invoice.status }} -
-
- OTB Billing
- By a contractor, for contractors -
-
- -
-
-

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 }} -
-
- - - - - - - - - - - - - - -
Service CodeServiceDescriptionTotal
{{ invoice.service_code or '-' }}{{ invoice.service_name or '-' }}{{ invoice.notes or '-' }}{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
- - - - - - - - - - - - - - - - - - - - - - -
Subtotal{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
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 invoice.notes %} -
- Notes

- {{ invoice.notes }} -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_pre_status_hardening_2026-03-08/app.py.bak b/backup_pre_status_hardening_2026-03-08/app.py.bak deleted file mode 100644 index fb4d762..0000000 --- a/backup_pre_status_hardening_2026-03-08/app.py.bak +++ /dev/null @@ -1,1138 +0,0 @@ -from flask import Flask, render_template, request, redirect -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at - 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 total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - return render_template("invoices/list.html", invoices=invoices) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - status = request.form.get("status", "").strip() - - if not status: - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=[], services=[], errors=["Status is required."], locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - status, - 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.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - amount_value = None - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - cursor.execute("SELECT client_id FROM invoices WHERE id = %s", (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - client_id = invoice["client_id"] - - 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, - c.client_code, - c.company_name, - i.total_amount, - i.amount_paid, - i.currency_code - FROM invoices i - JOIN clients c ON i.client_id = c.id - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_pre_status_hardening_2026-03-08/invoices_edit.html.bak b/backup_pre_status_hardening_2026-03-08/invoices_edit.html.bak deleted file mode 100644 index 5d2ab00..0000000 --- a/backup_pre_status_hardening_2026-03-08/invoices_edit.html.bak +++ /dev/null @@ -1,113 +0,0 @@ - - - -Edit Invoice - - - - -

Edit Invoice

- -

Home

-

Back to Invoices

- -{% if errors %} -
- Please fix the following: -
    - {% for error in errors %} -
  • {{ error }}
  • - {% endfor %} -
-
-{% endif %} - -{% if locked %} -
- This invoice is locked for core edits because payments exist.
- Core accounting fields cannot be changed after payment activity begins. -
-{% endif %} - -
- -

-Invoice Number
- -

- -{% if not locked %} -

-Client *
- -

- -

-Service *
- -

- -

-Currency *
- -

- -

-Total Amount *
- -

-{% else %} -

Client

-

Service

-

Currency

-

Total Amount

-{% endif %} - -

-Due Date *
- -

- -

-Status *
- -

- -

-Notes
- -

- -

- -

- -
- -{% include "footer.html" %} - - diff --git a/backup_pre_status_hardening_2026-03-08/invoices_list.html.bak b/backup_pre_status_hardening_2026-03-08/invoices_list.html.bak deleted file mode 100644 index 411a97c..0000000 --- a/backup_pre_status_hardening_2026-03-08/invoices_list.html.bak +++ /dev/null @@ -1,54 +0,0 @@ - - - -Invoices - - - - -

Invoices

- -

Home

-

Create Invoice

- - - - - - - - - - - - - - - - -{% for i in invoices %} - - - - - - - - - - - - - -{% endfor %} - -
IDInvoiceClientCurrencyTotalPaidRemainingStatusIssuedDueActions
{{ i.id }}{{ i.invoice_number }}{{ i.client_code }} - {{ i.company_name }}{{ i.currency_code }}{{ i.total_amount|money(i.currency_code) }}{{ i.amount_paid|money(i.currency_code) }}{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }}{{ i.status }}{{ i.issued_at|localtime }}{{ i.due_at|localtime }} - Edit - {% if i.payment_count > 0 %} - (Locked) - {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_restore_email_layer_2026-03-09/app.py.bak b/backup_restore_email_layer_2026-03-09/app.py.bak deleted file mode 100644 index 1051482..0000000 --- a/backup_restore_email_layer_2026-03-09/app.py.bak +++ /dev/null @@ -1,2342 +0,0 @@ -from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify -from db import get_db_connection -from utils import generate_client_code, generate_service_code -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from decimal import Decimal, InvalidOperation - -from io import BytesIO, StringIO -import csv -import zipfile -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas -from reportlab.lib.utils import ImageReader - -app = Flask( - __name__, - template_folder="../templates", - static_folder="../static", -) - -LOCAL_TZ = ZoneInfo("America/Toronto") - -def load_version(): - try: - with open("/home/def/otb_billing/VERSION", "r") as f: - return f.read().strip() - except Exception: - return "unknown" - -APP_VERSION = load_version() - -@app.context_processor -def inject_version(): - return {"app_version": APP_VERSION} - -@app.context_processor -def inject_app_settings(): - return {"app_settings": get_app_settings()} - -def fmt_local(dt_value): - if not dt_value: - return "" - if isinstance(dt_value, str): - try: - dt_value = datetime.fromisoformat(dt_value) - except ValueError: - return str(dt_value) - if dt_value.tzinfo is None: - dt_value = dt_value.replace(tzinfo=timezone.utc) - return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") - -def to_decimal(value): - if value is None or value == "": - return Decimal("0") - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError): - return Decimal("0") - -def fmt_money(value, currency_code="CAD"): - amount = to_decimal(value) - if currency_code == "CAD": - return f"{amount:.2f}" - return f"{amount:.8f}" - -def refresh_overdue_invoices(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - UPDATE invoices - SET status = 'overdue' - WHERE due_at IS NOT NULL - AND due_at < UTC_TIMESTAMP() - AND status IN ('pending', 'partial') - """) - conn.commit() - conn.close() - -def recalc_invoice_totals(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, total_amount, due_at, status - FROM invoices - WHERE id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return - - cursor.execute(""" - SELECT COALESCE(SUM(payment_amount), 0) AS total_paid - FROM payments - WHERE invoice_id = %s - AND payment_status = 'confirmed' - """, (invoice_id,)) - row = cursor.fetchone() - - total_paid = to_decimal(row["total_paid"]) - total_amount = to_decimal(invoice["total_amount"]) - - if invoice["status"] == "cancelled": - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET amount_paid = %s, - paid_at = NULL - WHERE id = %s - """, ( - str(total_paid), - invoice_id - )) - conn.commit() - conn.close() - return - - if total_paid >= total_amount and total_amount > 0: - new_status = "paid" - paid_at_value = "UTC_TIMESTAMP()" - elif total_paid > 0: - new_status = "partial" - paid_at_value = "NULL" - else: - if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): - new_status = "overdue" - else: - new_status = "pending" - paid_at_value = "NULL" - - update_cursor = conn.cursor() - update_cursor.execute(f""" - UPDATE invoices - SET amount_paid = %s, - status = %s, - paid_at = {paid_at_value} - WHERE id = %s - """, ( - str(total_paid), - new_status, - invoice_id - )) - - conn.commit() - conn.close() - -def get_client_credit_balance(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT COALESCE(SUM(amount), 0) AS balance - FROM credit_ledger - WHERE client_id = %s - """, (client_id,)) - row = cursor.fetchone() - conn.close() - return to_decimal(row["balance"]) - - -def generate_invoice_number(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT invoice_number - FROM invoices - WHERE invoice_number IS NOT NULL - AND invoice_number LIKE 'INV-%' - ORDER BY id DESC - LIMIT 1 - """) - row = cursor.fetchone() - conn.close() - - if not row or not row.get("invoice_number"): - return "INV-0001" - - invoice_number = str(row["invoice_number"]).strip() - - try: - number = int(invoice_number.split("-")[1]) - except (IndexError, ValueError): - return "INV-0001" - - return f"INV-{number + 1:04d}" - - -APP_SETTINGS_DEFAULTS = { - "business_name": "OTB Billing", - "business_tagline": "By a contractor, for contractors", - "business_logo_url": "", - "business_email": "", - "business_phone": "", - "business_address": "", - "business_website": "", - "tax_label": "HST", - "tax_rate": "13.00", - "tax_number": "", - "business_number": "", - "default_currency": "CAD", - "report_frequency": "monthly", - "invoice_footer": "", - "payment_terms": "", - "local_country": "Canada", - "apply_local_tax_only": "1", - "smtp_host": "", - "smtp_port": "587", - "smtp_user": "", - "smtp_pass": "", - "smtp_from_email": "", - "smtp_from_name": "", - "smtp_use_tls": "1", - "smtp_use_ssl": "0", -} - -def ensure_app_settings_table(): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS app_settings ( - setting_key VARCHAR(100) NOT NULL PRIMARY KEY, - setting_value TEXT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - """) - conn.commit() - conn.close() - -def get_app_settings(): - ensure_app_settings_table() - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT setting_key, setting_value - FROM app_settings - """) - rows = cursor.fetchall() - conn.close() - - settings = dict(APP_SETTINGS_DEFAULTS) - for row in rows: - settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else "" - - return settings - -def save_app_settings(form_data): - ensure_app_settings_table() - conn = get_db_connection() - cursor = conn.cursor() - - for key in APP_SETTINGS_DEFAULTS.keys(): - if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}: - value = "1" if form_data.get(key) else "0" - else: - value = (form_data.get(key) or "").strip() - - cursor.execute(""" - INSERT INTO app_settings (setting_key, setting_value) - VALUES (%s, %s) - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) - """, (key, value)) - - conn.commit() - conn.close() - - -@app.template_filter("localtime") -def localtime_filter(value): - return fmt_local(value) - -@app.template_filter("money") -def money_filter(value, currency_code="CAD"): - return fmt_money(value, currency_code) - - - - -def get_report_period_bounds(frequency): - now_local = datetime.now(LOCAL_TZ) - - if frequency == "yearly": - start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) - label = f"{now_local.year}" - elif frequency == "quarterly": - quarter = ((now_local.month - 1) // 3) + 1 - start_month = (quarter - 1) * 3 + 1 - start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0) - label = f"Q{quarter} {now_local.year}" - else: - start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - label = now_local.strftime("%B %Y") - - start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None) - end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None) - - return start_utc, end_utc, label - -def get_revenue_report_data(): - settings = get_app_settings() - frequency = (settings.get("report_frequency") or "monthly").strip().lower() - if frequency not in {"monthly", "quarterly", "yearly"}: - frequency = "monthly" - - start_utc, end_utc, label = get_report_period_bounds(frequency) - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected - FROM payments - WHERE payment_status = 'confirmed' - AND received_at >= %s - AND received_at <= %s - """, (start_utc, end_utc)) - collected_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS invoice_count, - COALESCE(SUM(total_amount), 0) AS invoiced - FROM invoices - WHERE issued_at >= %s - AND issued_at <= %s - """, (start_utc, end_utc)) - invoiced_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS overdue_count, - COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance - FROM invoices - WHERE status = 'overdue' - """) - overdue_row = cursor.fetchone() - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_count, - COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_row = cursor.fetchone() - - conn.close() - - return { - "frequency": frequency, - "period_label": label, - "period_start": start_utc.isoformat(sep=" "), - "period_end": end_utc.isoformat(sep=" "), - "collected_cad": str(to_decimal(collected_row["collected"])), - "invoice_count": int(invoiced_row["invoice_count"] or 0), - "invoiced_total": str(to_decimal(invoiced_row["invoiced"])), - "overdue_count": int(overdue_row["overdue_count"] or 0), - "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])), - "outstanding_count": int(outstanding_row["outstanding_count"] or 0), - "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])), - } - -@app.route("/settings", methods=["GET", "POST"]) -def settings(): - ensure_app_settings_table() - - if request.method == "POST": - save_app_settings(request.form) - return redirect("/settings") - - settings = get_app_settings() - return render_template("settings.html", settings=settings) - - -@app.route("/reports/revenue") -def revenue_report(): - report = get_revenue_report_data() - return render_template("reports/revenue.html", report=report) - -@app.route("/reports/revenue.json") -def revenue_report_json(): - report = get_revenue_report_data() - return jsonify(report) - -@app.route("/reports/revenue/print") -def revenue_report_print(): - report = get_revenue_report_data() - return render_template("reports/revenue_print.html", report=report) - -@app.route("/") -def index(): - refresh_overdue_invoices() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") - total_clients = cursor.fetchone()["total_clients"] - - cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") - active_services = cursor.fetchone()["active_services"] - - cursor.execute(""" - SELECT COUNT(*) AS outstanding_invoices - FROM invoices - WHERE status IN ('pending', 'partial', 'overdue') - """) - outstanding_invoices = cursor.fetchone()["outstanding_invoices"] - - cursor.execute(""" - SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received - FROM payments - WHERE payment_status = 'confirmed' - """) - revenue_received = cursor.fetchone()["revenue_received"] - - conn.close() - - return render_template( - "dashboard.html", - total_clients=total_clients, - active_services=active_services, - outstanding_invoices=outstanding_invoices, - revenue_received=revenue_received, - ) - -@app.route("/dbtest") -def dbtest(): - try: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT NOW()") - result = cursor.fetchone() - conn.close() - return f""" -

OTB Billing v{APP_VERSION}

-

Database OK

-

Home

-

DB server time (UTC): {result[0]}

-

Displayed local time: {fmt_local(result[0])}

- """ - except Exception as e: - return f"

Database FAILED

{e}
" - - - -@app.route("/clients/export.csv") -def export_clients_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - id, - client_code, - company_name, - contact_name, - email, - phone, - status, - created_at, - updated_at - FROM clients - ORDER BY id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "client_code", - "company_name", - "contact_name", - "email", - "phone", - "status", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("contact_name", ""), - r.get("email", ""), - r.get("phone", ""), - r.get("status", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=clients.csv" - return response - -@app.route("/clients") -def clients(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM clients ORDER BY id DESC") - clients = cursor.fetchall() - conn.close() - - for client in clients: - client["credit_balance"] = get_client_credit_balance(client["id"]) - - return render_template("clients/list.html", clients=clients) - -@app.route("/clients/new", methods=["GET", "POST"]) -def new_client(): - if request.method == "POST": - company_name = request.form["company_name"] - contact_name = request.form["contact_name"] - email = request.form["email"] - phone = request.form["phone"] - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT MAX(id) AS last_id FROM clients") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - - client_code = generate_client_code(company_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO clients - (client_code, company_name, contact_name, email, phone) - VALUES (%s, %s, %s, %s, %s) - """, - (client_code, company_name, contact_name, email, phone) - ) - conn.commit() - conn.close() - - return redirect("/clients") - - return render_template("clients/new.html") - -@app.route("/clients/edit/", methods=["GET", "POST"]) -def edit_client(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - company_name = request.form.get("company_name", "").strip() - contact_name = request.form.get("contact_name", "").strip() - email = request.form.get("email", "").strip() - phone = request.form.get("phone", "").strip() - status = request.form.get("status", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not company_name: - errors.append("Company name is required.") - if not status: - errors.append("Status is required.") - - if errors: - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - client["credit_balance"] = get_client_credit_balance(client_id) - conn.close() - return render_template("clients/edit.html", client=client, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE clients - SET company_name = %s, - contact_name = %s, - email = %s, - phone = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - company_name, - contact_name or None, - email or None, - phone or None, - status, - notes or None, - client_id - )) - conn.commit() - conn.close() - return redirect("/clients") - - cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) - client = cursor.fetchone() - conn.close() - - if not client: - return "Client not found", 404 - - client["credit_balance"] = get_client_credit_balance(client_id) - - return render_template("clients/edit.html", client=client, errors=[]) - -@app.route("/credits/") -def client_credits(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - cursor.execute(""" - SELECT * - FROM credit_ledger - WHERE client_id = %s - ORDER BY id DESC - """, (client_id,)) - entries = cursor.fetchall() - - conn.close() - - balance = get_client_credit_balance(client_id) - - return render_template( - "credits/list.html", - client=client, - entries=entries, - balance=balance, - ) - -@app.route("/credits/add/", methods=["GET", "POST"]) -def add_credit(client_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - WHERE id = %s - """, (client_id,)) - client = cursor.fetchone() - - if not client: - conn.close() - return "Client not found", 404 - - if request.method == "POST": - entry_type = request.form.get("entry_type", "").strip() - amount = request.form.get("amount", "").strip() - currency_code = request.form.get("currency_code", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not entry_type: - errors.append("Entry type is required.") - if not amount: - errors.append("Amount is required.") - if not currency_code: - errors.append("Currency code is required.") - - if not errors: - try: - amount_value = Decimal(str(amount)) - if amount_value == 0: - errors.append("Amount cannot be zero.") - except Exception: - errors.append("Amount must be a valid number.") - - if errors: - conn.close() - return render_template("credits/add.html", client=client, errors=errors) - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO credit_ledger - ( - client_id, - entry_type, - amount, - currency_code, - notes - ) - VALUES (%s, %s, %s, %s, %s) - """, ( - client_id, - entry_type, - amount, - currency_code, - notes or None - )) - conn.commit() - conn.close() - - return redirect(f"/credits/{client_id}") - - conn.close() - return render_template("credits/add.html", client=client, errors=[]) - -@app.route("/services") -def services(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT s.*, c.client_code, c.company_name - FROM services s - JOIN clients c ON s.client_id = c.id - ORDER BY s.id DESC - """) - services = cursor.fetchall() - conn.close() - return render_template("services/list.html", services=services) - -@app.route("/services/new", methods=["GET", "POST"]) -def new_service(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form["client_id"] - service_name = request.form["service_name"] - service_type = request.form["service_type"] - billing_cycle = request.form["billing_cycle"] - currency_code = request.form["currency_code"] - recurring_amount = request.form["recurring_amount"] - status = request.form["status"] - start_date = request.form["start_date"] or None - description = request.form["description"] - - cursor.execute("SELECT MAX(id) AS last_id FROM services") - result = cursor.fetchone() - last_number = result["last_id"] if result["last_id"] else 0 - service_code = generate_service_code(service_name, last_number) - - insert_cursor = conn.cursor() - insert_cursor.execute( - """ - INSERT INTO services - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - client_id, - service_code, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date, - description - ) - ) - conn.commit() - conn.close() - - return redirect("/services") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - return render_template("services/new.html", clients=clients) - -@app.route("/services/edit/", methods=["GET", "POST"]) -def edit_service(service_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_name = request.form.get("service_name", "").strip() - service_type = request.form.get("service_type", "").strip() - billing_cycle = request.form.get("billing_cycle", "").strip() - currency_code = request.form.get("currency_code", "").strip() - recurring_amount = request.form.get("recurring_amount", "").strip() - status = request.form.get("status", "").strip() - start_date = request.form.get("start_date", "").strip() - description = request.form.get("description", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_name: - errors.append("Service name is required.") - if not service_type: - errors.append("Service type is required.") - if not billing_cycle: - errors.append("Billing cycle is required.") - if not currency_code: - errors.append("Currency code is required.") - if not recurring_amount: - errors.append("Recurring amount is required.") - if not status: - errors.append("Status is required.") - - if not errors: - try: - recurring_amount_value = float(recurring_amount) - if recurring_amount_value < 0: - errors.append("Recurring amount cannot be negative.") - except ValueError: - errors.append("Recurring amount must be a valid number.") - - if errors: - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - - conn.close() - return render_template("services/edit.html", service=service, clients=clients, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE services - SET client_id = %s, - service_name = %s, - service_type = %s, - billing_cycle = %s, - status = %s, - currency_code = %s, - recurring_amount = %s, - start_date = %s, - description = %s - WHERE id = %s - """, ( - client_id, - service_name, - service_type, - billing_cycle, - status, - currency_code, - recurring_amount, - start_date or None, - description or None, - service_id - )) - conn.commit() - conn.close() - return redirect("/services") - - cursor.execute(""" - SELECT s.*, c.company_name - FROM services s - LEFT JOIN clients c ON s.client_id = c.id - WHERE s.id = %s - """, (service_id,)) - service = cursor.fetchone() - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") - clients = cursor.fetchall() - conn.close() - - if not service: - return "Service not found", 404 - - return render_template("services/edit.html", service=service, clients=clients, errors=[]) - - - - - - -@app.route("/invoices/export.csv") -def export_invoices_csv(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.id, - i.invoice_number, - i.client_id, - c.client_code, - c.company_name, - i.service_id, - i.currency_code, - i.subtotal_amount, - i.tax_amount, - i.total_amount, - i.amount_paid, - i.status, - i.issued_at, - i.due_at, - i.paid_at, - i.notes, - i.created_at, - i.updated_at - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "service_id", - "currency_code", - "subtotal_amount", - "tax_amount", - "total_amount", - "amount_paid", - "status", - "issued_at", - "due_at", - "paid_at", - "notes", - "created_at", - "updated_at", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("service_id", ""), - r.get("currency_code", ""), - r.get("subtotal_amount", ""), - r.get("tax_amount", ""), - r.get("total_amount", ""), - r.get("amount_paid", ""), - r.get("status", ""), - r.get("issued_at", ""), - r.get("due_at", ""), - r.get("paid_at", ""), - r.get("notes", ""), - r.get("created_at", ""), - r.get("updated_at", ""), - ]) - - filename = "invoices" - if start_date or end_date or status or client_id or limit_count: - filename += "_filtered" - filename += ".csv" - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = f"attachment; filename={filename}" - return response - - -@app.route("/invoices/export-pdf.zip") -def export_invoices_pdf_zip(): - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - def build_invoice_pdf_bytes(invoice, settings): - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - terms = settings.get("payment_terms", "") - for chunk_start in range(0, len(terms), 90): - line_text = terms[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - footer = settings.get("invoice_footer", "") - for chunk_start in range(0, len(footer), 90): - line_text = footer[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - return buffer.getvalue() - - zip_buffer = BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - for invoice in invoices: - pdf_bytes = build_invoice_pdf_bytes(invoice, settings) - zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes) - - zip_buffer.seek(0) - - filename = "invoices_export" - if start_date: - filename += f"_{start_date}" - if end_date: - filename += f"_to_{end_date}" - if status: - filename += f"_{status}" - if client_id: - filename += f"_client_{client_id}" - if limit_count: - filename += f"_limit_{limit_count}" - filename += ".zip" - - return send_file( - zip_buffer, - mimetype="application/zip", - as_attachment=True, - download_name=filename - ) - - -@app.route("/invoices/print") -def print_invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id ASC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - conn.close() - - settings = get_app_settings() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters) - -@app.route("/invoices") -def invoices(): - refresh_overdue_invoices() - - start_date = (request.args.get("start_date") or "").strip() - end_date = (request.args.get("end_date") or "").strip() - status = (request.args.get("status") or "").strip() - client_id = (request.args.get("client_id") or "").strip() - limit_count = (request.args.get("limit") or "").strip() - - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - query = """ - SELECT - i.*, - c.client_code, - c.company_name, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE 1=1 - """ - params = [] - - if start_date: - query += " AND DATE(i.issued_at) >= %s" - params.append(start_date) - - if end_date: - query += " AND DATE(i.issued_at) <= %s" - params.append(end_date) - - if status: - query += " AND i.status = %s" - params.append(status) - - if client_id: - query += " AND i.client_id = %s" - params.append(client_id) - - query += " ORDER BY i.id DESC" - - if limit_count: - try: - limit_int = int(limit_count) - if limit_int > 0: - query += " LIMIT %s" - params.append(limit_int) - except ValueError: - pass - - cursor.execute(query, tuple(params)) - invoices = cursor.fetchall() - - cursor.execute(""" - SELECT id, client_code, company_name - FROM clients - ORDER BY company_name ASC - """) - clients = cursor.fetchall() - - conn.close() - - filters = { - "start_date": start_date, - "end_date": end_date, - "status": status, - "client_id": client_id, - "limit": limit_count, - } - - return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) - -@app.route("/invoices/new", methods=["GET", "POST"]) -def new_invoice(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value <= 0: - errors.append("Total amount must be greater than zero.") - except ValueError: - errors.append("Total amount must be a valid number.") - - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "total_amount": total_amount, - "due_at": due_at, - "notes": notes, - } - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=errors, - form_data=form_data, - ) - - invoice_number = generate_invoice_number() - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO invoices - ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - subtotal_amount, - issued_at, - due_at, - status, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s) - """, ( - client_id, - service_id, - invoice_number, - currency_code, - total_amount, - total_amount, - due_at, - notes - )) - - conn.commit() - conn.close() - - return redirect("/invoices") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - - return render_template( - "invoices/new.html", - clients=clients, - services=services, - errors=[], - form_data={}, - ) - - - - - -@app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - - settings = get_app_settings() - - buffer = BytesIO() - pdf = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - left = 50 - right = 560 - y = height - 50 - - def draw_line(txt, x=left, font="Helvetica", size=11): - nonlocal y - pdf.setFont(font, size) - pdf.drawString(x, y, str(txt) if txt is not None else "") - y -= 16 - - def money(value, currency="CAD"): - return f"{to_decimal(value):.2f} {currency}" - - pdf.setTitle(f"Invoice {invoice['invoice_number']}") - - logo_url = (settings.get("business_logo_url") or "").strip() - if logo_url.startswith("/static/"): - local_logo_path = "/home/def/otb_billing" + logo_url - try: - pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') - except Exception: - pass - - pdf.setFont("Helvetica-Bold", 22) - pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") - - pdf.setFont("Helvetica-Bold", 14) - pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") - y -= 18 - pdf.setFont("Helvetica", 12) - pdf.drawRightString(right, y, settings.get("business_tagline") or "") - y -= 15 - - right_lines = [ - settings.get("business_address", ""), - settings.get("business_email", ""), - settings.get("business_phone", ""), - settings.get("business_website", ""), - ] - for item in right_lines: - if item: - pdf.drawRightString(right, y, item[:80]) - y -= 14 - - y -= 10 - - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, "Status:") - pdf.setFont("Helvetica", 12) - pdf.drawString(left + 45, y, str(invoice["status"]).upper()) - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Bill To") - y -= 20 - pdf.setFont("Helvetica-Bold", 12) - pdf.drawString(left, y, invoice["company_name"] or "") - y -= 16 - pdf.setFont("Helvetica", 11) - if invoice.get("contact_name"): - pdf.drawString(left, y, str(invoice["contact_name"])) - y -= 15 - if invoice.get("email"): - pdf.drawString(left, y, str(invoice["email"])) - y -= 15 - if invoice.get("phone"): - pdf.drawString(left, y, str(invoice["phone"])) - y -= 15 - pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(left, y, "Invoice Details") - y -= 20 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") - y -= 15 - pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") - y -= 15 - pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") - y -= 15 - if invoice.get("paid_at"): - pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") - y -= 15 - pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") - y -= 28 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") - y -= 14 - pdf.line(left, y, right, y) - y -= 18 - - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 - - totals_x_label = 360 - totals_x_value = right - - totals = [ - ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), - ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), - ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] - - remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) - - for label, value in totals: - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, label) - pdf.setFont("Helvetica", 11) - pdf.drawRightString(totals_x_value, y, value) - y -= 18 - - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(totals_x_label, y, "Remaining") - pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") - y -= 25 - - if settings.get("tax_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") - y -= 14 - - if settings.get("business_number"): - pdf.setFont("Helvetica", 10) - pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") - y -= 14 - - if settings.get("payment_terms"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Payment Terms") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): - line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - if settings.get("invoice_footer"): - y -= 8 - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(left, y, "Footer") - y -= 15 - pdf.setFont("Helvetica", 10) - for chunk_start in range(0, len(settings.get("invoice_footer", "")), 90): - line_text = settings.get("invoice_footer", "")[chunk_start:chunk_start+90] - pdf.drawString(left, y, line_text) - y -= 13 - - pdf.showPage() - pdf.save() - buffer.seek(0) - - return send_file( - buffer, - mimetype="application/pdf", - as_attachment=True, - download_name=f"{invoice['invoice_number']}.pdf" - ) - - -@app.route("/invoices/view/") -def view_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - i.*, - c.client_code, - c.company_name, - c.contact_name, - c.email, - c.phone, - s.service_code, - s.service_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - LEFT JOIN services s ON i.service_id = s.id - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - conn.close() - settings = get_app_settings() - return render_template("invoices/view.html", invoice=invoice, settings=settings) - - -@app.route("/invoices/edit/", methods=["GET", "POST"]) -def edit_invoice(invoice_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT i.*, - COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count - FROM invoices i - WHERE i.id = %s - """, (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 - - if request.method == "POST": - due_at = request.form.get("due_at", "").strip() - notes = request.form.get("notes", "").strip() - - if locked: - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET due_at = %s, - notes = %s - WHERE id = %s - """, ( - due_at or None, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - client_id = request.form.get("client_id", "").strip() - service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - total_amount = request.form.get("total_amount", "").strip() - status = request.form.get("status", "").strip() - - errors = [] - - if not client_id: - errors.append("Client is required.") - if not service_id: - errors.append("Service is required.") - if not currency_code: - errors.append("Currency is required.") - if not total_amount: - errors.append("Total amount is required.") - if not due_at: - errors.append("Due date is required.") - if not status: - errors.append("Status is required.") - - manual_statuses = {"draft", "pending", "cancelled"} - if status and status not in manual_statuses: - errors.append("Manual invoice status must be draft, pending, or cancelled.") - - if not errors: - try: - amount_value = float(total_amount) - if amount_value < 0: - errors.append("Total amount cannot be negative.") - except ValueError: - errors.append("Total amount must be a valid number.") - - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - if errors: - invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] - invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] - invoice["currency_code"] = currency_code or invoice["currency_code"] - invoice["total_amount"] = total_amount or invoice["total_amount"] - invoice["due_at"] = due_at or invoice["due_at"] - invoice["status"] = status or invoice["status"] - invoice["notes"] = notes - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE invoices - SET client_id = %s, - service_id = %s, - currency_code = %s, - total_amount = %s, - subtotal_amount = %s, - due_at = %s, - status = %s, - notes = %s - WHERE id = %s - """, ( - client_id, - service_id, - currency_code, - total_amount, - total_amount, - due_at, - status, - notes or None, - invoice_id - )) - conn.commit() - conn.close() - return redirect("/invoices") - - clients = [] - services = [] - - if not locked: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() - - cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() - - conn.close() - return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) - - - -@app.route("/payments/export.csv") -def export_payments_csv(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute(""" - SELECT - p.id, - p.invoice_id, - i.invoice_number, - p.client_id, - c.client_code, - c.company_name, - p.payment_method, - p.payment_currency, - p.payment_amount, - p.cad_value_at_payment, - p.reference, - p.sender_name, - p.txid, - p.wallet_address, - p.payment_status, - p.received_at, - p.notes - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id ASC - """) - rows = cursor.fetchall() - conn.close() - - output = StringIO() - writer = csv.writer(output) - writer.writerow([ - "id", - "invoice_id", - "invoice_number", - "client_id", - "client_code", - "company_name", - "payment_method", - "payment_currency", - "payment_amount", - "cad_value_at_payment", - "reference", - "sender_name", - "txid", - "wallet_address", - "payment_status", - "received_at", - "notes", - ]) - - for r in rows: - writer.writerow([ - r.get("id", ""), - r.get("invoice_id", ""), - r.get("invoice_number", ""), - r.get("client_id", ""), - r.get("client_code", ""), - r.get("company_name", ""), - r.get("payment_method", ""), - r.get("payment_currency", ""), - r.get("payment_amount", ""), - r.get("cad_value_at_payment", ""), - r.get("reference", ""), - r.get("sender_name", ""), - r.get("txid", ""), - r.get("wallet_address", ""), - r.get("payment_status", ""), - r.get("received_at", ""), - r.get("notes", ""), - ]) - - response = make_response(output.getvalue()) - response.headers["Content-Type"] = "text/csv; charset=utf-8" - response.headers["Content-Disposition"] = "attachment; filename=payments.csv" - return response - -@app.route("/payments") -def payments(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - i.status AS invoice_status, - i.total_amount, - i.amount_paid, - i.currency_code AS invoice_currency_code, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - ORDER BY p.id DESC - """) - payments = cursor.fetchall() - - conn.close() - return render_template("payments/list.html", payments=payments) - -@app.route("/payments/new", methods=["GET", "POST"]) -def new_payment(): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - if request.method == "POST": - invoice_id = request.form.get("invoice_id", "").strip() - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not invoice_id: - errors.append("Invoice is required.") - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - payment_amount_value = Decimal(str(payment_amount)) - if payment_amount_value <= Decimal("0"): - errors.append("Payment amount must be greater than zero.") - except Exception: - errors.append("Payment amount must be a valid number.") - - if not errors: - try: - cad_value_value = Decimal(str(cad_value_at_payment)) - if cad_value_value < Decimal("0"): - errors.append("CAD value at payment cannot be negative.") - except Exception: - errors.append("CAD value at payment must be a valid number.") - - invoice_row = None - - if not errors: - cursor.execute(""" - SELECT - i.id, - i.client_id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.id = %s - """, (invoice_id,)) - invoice_row = cursor.fetchone() - - if not invoice_row: - errors.append("Selected invoice was not found.") - else: - allowed_statuses = {"pending", "partial", "overdue"} - if invoice_row["status"] not in allowed_statuses: - errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") - else: - remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) - entered_amount = to_decimal(payment_amount) - - if remaining_balance <= Decimal("0"): - errors.append("This invoice has no remaining balance.") - elif entered_amount > remaining_balance: - errors.append( - f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." - ) - - if errors: - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - form_data = { - "invoice_id": invoice_id, - "payment_method": payment_method, - "payment_currency": payment_currency, - "payment_amount": payment_amount, - "cad_value_at_payment": cad_value_at_payment, - "reference": reference, - "sender_name": sender_name, - "txid": txid, - "wallet_address": wallet_address, - "notes": notes, - } - - return render_template( - "payments/new.html", - invoices=invoices, - errors=errors, - form_data=form_data, - ) - - client_id = invoice_row["client_id"] - - insert_cursor = conn.cursor() - insert_cursor.execute(""" - INSERT INTO payments - ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference, - sender_name, - txid, - wallet_address, - payment_status, - received_at, - notes - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) - """, ( - invoice_id, - client_id, - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None - )) - - conn.commit() - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - cursor.execute(""" - SELECT - i.id, - i.invoice_number, - i.currency_code, - i.total_amount, - i.amount_paid, - i.status, - c.client_code, - c.company_name - FROM invoices i - JOIN clients c ON i.client_id = c.id - WHERE i.status IN ('pending', 'partial', 'overdue') - AND (i.total_amount - i.amount_paid) > 0 - ORDER BY i.id DESC - """) - invoices = cursor.fetchall() - conn.close() - - return render_template( - "payments/new.html", - invoices=invoices, - errors=[], - form_data={}, - ) - - - -@app.route("/payments/void/", methods=["POST"]) -def void_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT id, invoice_id, payment_status - FROM payments - WHERE id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if payment["payment_status"] != "confirmed": - conn.close() - return redirect("/payments") - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_status = 'reversed' - WHERE id = %s - """, (payment_id,)) - - conn.commit() - conn.close() - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - - recalc_invoice_totals(payment["invoice_id"]) - - return redirect("/payments") - -@app.route("/payments/edit/", methods=["GET", "POST"]) -def edit_payment(payment_id): - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - - cursor.execute(""" - SELECT - p.*, - i.invoice_number, - c.client_code, - c.company_name - FROM payments p - JOIN invoices i ON p.invoice_id = i.id - JOIN clients c ON p.client_id = c.id - WHERE p.id = %s - """, (payment_id,)) - payment = cursor.fetchone() - - if not payment: - conn.close() - return "Payment not found", 404 - - if request.method == "POST": - payment_method = request.form.get("payment_method", "").strip() - payment_currency = request.form.get("payment_currency", "").strip() - payment_amount = request.form.get("payment_amount", "").strip() - cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() - reference = request.form.get("reference", "").strip() - sender_name = request.form.get("sender_name", "").strip() - txid = request.form.get("txid", "").strip() - wallet_address = request.form.get("wallet_address", "").strip() - notes = request.form.get("notes", "").strip() - - errors = [] - - if not payment_method: - errors.append("Payment method is required.") - if not payment_currency: - errors.append("Payment currency is required.") - if not payment_amount: - errors.append("Payment amount is required.") - if not cad_value_at_payment: - errors.append("CAD value at payment is required.") - - if not errors: - try: - amount_value = float(payment_amount) - if amount_value <= 0: - errors.append("Payment amount must be greater than zero.") - except ValueError: - errors.append("Payment amount must be a valid number.") - - try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: - errors.append("CAD value at payment cannot be negative.") - except ValueError: - errors.append("CAD value at payment must be a valid number.") - - if errors: - payment["payment_method"] = payment_method or payment["payment_method"] - payment["payment_currency"] = payment_currency or payment["payment_currency"] - payment["payment_amount"] = payment_amount or payment["payment_amount"] - payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] - payment["reference"] = reference - payment["sender_name"] = sender_name - payment["txid"] = txid - payment["wallet_address"] = wallet_address - payment["notes"] = notes - conn.close() - return render_template("payments/edit.html", payment=payment, errors=errors) - - update_cursor = conn.cursor() - update_cursor.execute(""" - UPDATE payments - SET payment_method = %s, - payment_currency = %s, - payment_amount = %s, - cad_value_at_payment = %s, - reference = %s, - sender_name = %s, - txid = %s, - wallet_address = %s, - notes = %s - WHERE id = %s - """, ( - payment_method, - payment_currency, - payment_amount, - cad_value_at_payment, - reference or None, - sender_name or None, - txid or None, - wallet_address or None, - notes or None, - payment_id - )) - conn.commit() - invoice_id = payment["invoice_id"] - conn.close() - - recalc_invoice_totals(invoice_id) - - return redirect("/payments") - - conn.close() - return render_template("payments/edit.html", payment=payment, errors=[]) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/backup_restore_email_layer_2026-03-09/invoices_view.html.bak b/backup_restore_email_layer_2026-03-09/invoices_view.html.bak deleted file mode 100644 index d9e7557..0000000 --- a/backup_restore_email_layer_2026-03-09/invoices_view.html.bak +++ /dev/null @@ -1,235 +0,0 @@ - - - -Invoice {{ invoice.invoice_number }} - - - - -
- {% if request.args.get('email_sent') == '1' %} -
- Invoice email sent successfully. -
- {% endif %} - {% if request.args.get('email_failed') == '1' %} -
- Invoice email failed. Check SMTP settings or server log. -
- {% endif %} - - -
- -{% if settings.business_logo_url %} - -{% endif %} - -
-

Invoice {{ invoice.invoice_number }}

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

Bill To

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

Invoice Details

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

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

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

- {{ settings.invoice_footer }} -
- {% endif %} -
- -{% include "footer.html" %} - - diff --git a/backup_fix_void_route_2026-03-08/app.py.bak b/build-backups/backup_fix_void_route_2026-03-08/app.py.bak similarity index 100% rename from backup_fix_void_route_2026-03-08/app.py.bak rename to build-backups/backup_fix_void_route_2026-03-08/app.py.bak diff --git a/backup_fix_void_route_2026-03-08/payments_list.html.bak b/build-backups/backup_fix_void_route_2026-03-08/payments_list.html.bak similarity index 100% rename from backup_fix_void_route_2026-03-08/payments_list.html.bak rename to build-backups/backup_fix_void_route_2026-03-08/payments_list.html.bak diff --git a/backup_logo_support_2026-03-09/app.py.bak b/build-backups/backup_logo_support_2026-03-09/app.py.bak similarity index 100% rename from backup_logo_support_2026-03-09/app.py.bak rename to build-backups/backup_logo_support_2026-03-09/app.py.bak diff --git a/backup_logo_support_2026-03-09/dashboard.html.bak b/build-backups/backup_logo_support_2026-03-09/dashboard.html.bak similarity index 100% rename from backup_logo_support_2026-03-09/dashboard.html.bak rename to build-backups/backup_logo_support_2026-03-09/dashboard.html.bak diff --git a/backup_logo_support_2026-03-09/invoice_view.html.bak b/build-backups/backup_logo_support_2026-03-09/invoice_view.html.bak similarity index 100% rename from backup_logo_support_2026-03-09/invoice_view.html.bak rename to build-backups/backup_logo_support_2026-03-09/invoice_view.html.bak diff --git a/backup_logo_support_2026-03-09/settings.html.bak b/build-backups/backup_logo_support_2026-03-09/settings.html.bak similarity index 100% rename from backup_logo_support_2026-03-09/settings.html.bak rename to build-backups/backup_logo_support_2026-03-09/settings.html.bak diff --git a/backup_pre_accounting_package_2026-03-09/app.py.bak b/build-backups/backup_restore_email_layer_2026-03-09/app.py.bak similarity index 100% rename from backup_pre_accounting_package_2026-03-09/app.py.bak rename to build-backups/backup_restore_email_layer_2026-03-09/app.py.bak diff --git a/backup_restore_email_layer_2026-03-09/dashboard.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/dashboard.html.bak similarity index 100% rename from backup_restore_email_layer_2026-03-09/dashboard.html.bak rename to build-backups/backup_restore_email_layer_2026-03-09/dashboard.html.bak diff --git a/backup_pre_email_log_2026-03-09/invoices_view.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/invoices_view.html.bak similarity index 100% rename from backup_pre_email_log_2026-03-09/invoices_view.html.bak rename to build-backups/backup_restore_email_layer_2026-03-09/invoices_view.html.bak diff --git a/backup_restore_email_layer_2026-03-09/revenue.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/revenue.html.bak similarity index 100% rename from backup_restore_email_layer_2026-03-09/revenue.html.bak rename to build-backups/backup_restore_email_layer_2026-03-09/revenue.html.bak diff --git a/backup_restore_email_layer_2026-03-09/settings.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/settings.html.bak similarity index 100% rename from backup_restore_email_layer_2026-03-09/settings.html.bak rename to build-backups/backup_restore_email_layer_2026-03-09/settings.html.bak diff --git a/emailfix.sh b/emailfix.sh new file mode 100755 index 0000000..de75c3a --- /dev/null +++ b/emailfix.sh @@ -0,0 +1,45 @@ +cd /home/def/otb_billing/backend || exit 1 + +STAMP="$(date +%Y%m%d-%H%M%S)" +cp app.py "app.py.email-attachment.${STAMP}.bak" + +python3 <<'PY' +from pathlib import Path + +p = Path("app.py") +t = p.read_text() + +# Find the payment confirmation email block +old = "attachments=None," + +new = """attachments=[{ + "filename": f"invoice_{invoice.get('invoice_number')}.pdf", + "content": generate_invoice_pdf_bytes(invoice_id), + "mime": "application/pdf" + }],""" + +if old not in t: + raise SystemExit("FAILED: attachments=None not found") + +t = t.replace(old, new, 1) + +# Add helper function if missing +if "def generate_invoice_pdf_bytes" not in t: + t += """ + +def generate_invoice_pdf_bytes(invoice_id): + from io import BytesIO + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, f"Invoice #{invoice_id}") + c.drawString(100, 730, "Generated by OTB Billing") + c.save() + buffer.seek(0) + return buffer.read() +""" + +p.write_text(t) +print("OK: email attachment patch applied") +PY + +sudo systemctl restart otb_billing.service diff --git a/info.txt b/info.txt new file mode 100644 index 0000000..e69de29 diff --git a/otb-portal-nav.txt b/otb-portal-nav.txt new file mode 100644 index 0000000..5770d58 --- /dev/null +++ b/otb-portal-nav.txt @@ -0,0 +1,332 @@ +=== FILE LIST === +/home/def/otb_billing/backend/app.py +/home/def/otb_billing/static/css/style.css +/home/def/otb_billing/templates/base.html +/home/def/otb_billing/templates/clients/edit.html +/home/def/otb_billing/templates/clients/list.html +/home/def/otb_billing/templates/clients/new.html +/home/def/otb_billing/templates/credits/add.html +/home/def/otb_billing/templates/credits/list.html +/home/def/otb_billing/templates/dashboard.html +/home/def/otb_billing/templates/footer.html +/home/def/otb_billing/templates/health.html +/home/def/otb_billing/templates/invoices/edit.html +/home/def/otb_billing/templates/invoices/list.html +/home/def/otb_billing/templates/invoices/new.html +/home/def/otb_billing/templates/invoices/print_batch.html +/home/def/otb_billing/templates/invoices/view.html +/home/def/otb_billing/templates/payments/edit.html +/home/def/otb_billing/templates/payments/list.html +/home/def/otb_billing/templates/payments/new.html +/home/def/otb_billing/templates/portal_dashboard.html +/home/def/otb_billing/templates/portal_forgot_password.html +/home/def/otb_billing/templates/portal_invoice_detail.html +/home/def/otb_billing/templates/portal_login.html +/home/def/otb_billing/templates/portal_set_password.html +/home/def/otb_billing/templates/reports/aging.html +/home/def/otb_billing/templates/reports/revenue.html +/home/def/otb_billing/templates/reports/revenue_print.html +/home/def/otb_billing/templates/services/edit.html +/home/def/otb_billing/templates/services/list.html +/home/def/otb_billing/templates/services/new.html +/home/def/otb_billing/templates/settings.html +/home/def/otb_billing/templates/subscriptions/list.html +/home/def/otb_billing/templates/subscriptions/new.html + +=== APP.PY === +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = 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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + +=== templates/base.html === + + + + +{{ page_title }} + + + + + + +
+

OTB Billing

+
+ +
+

{{ content }}

+
+ + + +{% include "footer.html" %} + +=== /home/def/otb_billing/static/css/style.css === +body { + font-family: Arial; + background: #0f172a; + color: #e5e7eb; +} + +header { + padding: 20px; + background: #111827; +} diff --git a/otb-portal-pages.txt b/otb-portal-pages.txt new file mode 100644 index 0000000..8f1c066 --- /dev/null +++ b/otb-portal-pages.txt @@ -0,0 +1,709 @@ +=== /home/def/otb_billing/templates/portal_login.html === + + + + + + Client Portal - OutsideTheBox + + + + + + + +
+
+

OutsideTheBox Client Portal

+

Secure access for invoices, balances, and account information.

+ + {% if portal_message %} +
{{ portal_message }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + Home + Contact Support +
+
+ + + + +

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

+
+
+ +{% include "footer.html" %} + + + +=== /home/def/otb_billing/templates/portal_dashboard.html === + + + + + + Client Dashboard - OutsideTheBox + + + + + + + +
+
+
+

Client Dashboard

+

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

+
+ +
+ +
+
+

Total Invoices

+
{{ invoice_count }}
+
+
+

Total Outstanding

+
{{ total_outstanding }}
+
+
+

Total Paid

+
{{ total_paid }}
+
+
+ +

Invoices

+ + + + + + + + + + + + + {% for row in invoices %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
InvoiceStatusCreatedTotalPaidOutstanding
+ + {{ row.invoice_number or ("INV-" ~ row.id) }} + + + {% set s = (row.status or "")|lower %} + {% if s == "paid" %} + {{ row.status }} + {% elif s == "pending" %} + {{ row.status }} + {% elif s == "overdue" %} + {{ row.status }} + {% else %} + {{ row.status }} + {% endif %} + {{ row.created_at }}{{ row.total_amount }}{{ row.amount_paid }}{{ row.outstanding }}
No invoices available.
+
+ + + + +{% include "footer.html" %} + + + +=== /home/def/otb_billing/templates/portal_invoice_detail.html === + + + + + + Invoice Detail - OutsideTheBox + + + + + + + +
+
+
+

Invoice Detail

+

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

+
+ +
+ + {% if (invoice.status or "")|lower == "paid" %} +
✓ This invoice has been paid. Thank you!
+ {% endif %} + + {% if crypto_error %} +
{{ crypto_error }}
+ {% endif %} + +
+

Invoice

{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
+
+

Status

+ {% set s = (invoice.status or "")|lower %} + {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} + processing + {% elif s == "paid" %}{{ invoice.status }} + {% elif s == "pending" %}{{ invoice.status }} + {% elif s == "overdue" %}{{ invoice.status }} + {% else %}{{ invoice.status }}{% endif %} +
+

Created

{{ invoice.created_at }}
+

Total

{{ invoice.total_amount }}
+

Paid

{{ invoice.amount_paid }}
+

Outstanding

{{ invoice.outstanding }}
+
+ +

Invoice Items

+ + + + {% for item in items %} + + {% else %} + + {% endfor %} + +
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.
+ + {% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} +
+

Pay Now

+
+ + +
+ +
+

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

+
+ +
+

Credit Card (Square)

+ Pay with Credit Card +
+ +
+ {% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} +
+
+
+

Crypto Quote Snapshot

+
Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
+
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
+
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
+ {% if pending_crypto_payment %} +
Your quote is protected after acceptance.
+ {% else %} +
Select a crypto asset to accept the quote.
+ {% endif %} +
+ + {% if pending_crypto_payment and pending_crypto_payment.txid %} +
+
--:--
+
Watching transaction / waiting for confirmation
+
+ {% elif pending_crypto_payment %} +
+
--:--
+
Quote protected while you open wallet
+
+ {% else %} +
+
--:--
+
This price times out:
+
+ {% endif %} +
+ + {% if pending_crypto_payment and selected_crypto_option %} +
+
+
+

{{ selected_crypto_option.label }} Payment Instructions

+
Send exactly: {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}
+
Destination wallet:
+ {{ pending_crypto_payment.wallet_address }} +
Reference / Invoice:
+ {{ pending_crypto_payment.reference }} + + {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} +
+ + Back to Portal Login + Contact Support +
+ +
+
+ +{% include "footer.html" %} + + + +=== /home/def/otb_billing/templates/portal_set_password.html === + + + + + + Set Portal Password - OutsideTheBox + + + + + + + +
+
+

Create Your Portal Password

+

Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.

+ + {% if portal_message %} +
{{ portal_message }}
+ {% endif %} + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +{% include "footer.html" %} + + diff --git a/otb-saas-panel.txt b/otb-saas-panel.txt new file mode 100644 index 0000000..e5eeed8 --- /dev/null +++ b/otb-saas-panel.txt @@ -0,0 +1,116 @@ +=== /home/def/otb_billing/templates/portal_dashboard.html === + + + Client Dashboard - OutsideTheBox + + +{% include "includes/site_nav.html" %}

Client Dashboard

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

Total Invoices

{{ invoice_count }}

Total Outstanding

{{ total_outstanding }}

Total Paid

{{ total_paid }}

Invoices

{% for row in invoices %} {% else %} {% endfor %}
Invoice Status Created Total Paid Outstanding
{{ row.invoice_number or ("INV-" ~ row.id) }} {% set s = (row.status or "")|lower %} {% if s == "paid" %} {{ row.status }} {% elif s == "pending" %} {{ row.status }} {% elif s == "overdue" %} {{ row.status }} {% else %} {{ row.status }} {% endif %} {{ row.created_at }} {{ row.total_amount }} {{ row.amount_paid }} {{ row.outstanding }}
No invoices available.
+
{% include "footer.html" %} + + + +=== /home/def/otb_billing/templates/portal_invoice_detail.html === + + + Invoice Detail - OutsideTheBox + + +{% include "includes/site_nav.html" %}

Invoice Detail

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

{% if (invoice.status or "")|lower == "paid" %}
✓ This invoice has been paid. Thank you!
{% endif %} {% if crypto_error %}
{{ crypto_error }}
{% endif %}

Invoice

{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Status

{% set s = (invoice.status or "")|lower %} {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} processing {% elif s == "paid" %}{{ invoice.status }} {% elif s == "pending" %}{{ invoice.status }} {% elif s == "overdue" %}{{ invoice.status }} {% else %}{{ invoice.status }}{% endif %}

Created

{{ invoice.created_at }}

Total

{{ invoice.total_amount }}

Paid

{{ invoice.amount_paid }}

Outstanding

{{ invoice.outstanding }}

Invoice Items

{% for item in items %} {% else %} {% endfor %}
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %}

Pay Now

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

Credit Card (Square)

Pay with Credit Card
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %}

Crypto Quote Snapshot

Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
{% if pending_crypto_payment %}
Your quote is protected after acceptance.
{% else %}
Select a crypto asset to accept the quote.
{% endif %}
{% if pending_crypto_payment and pending_crypto_payment.txid %}
--:--
Watching transaction / waiting for confirmation
{% elif pending_crypto_payment %}
--:--
Quote protected while you open wallet
{% else %}
--:--
This price times out:
{% endif %}
{% if pending_crypto_payment and selected_crypto_option %}

{{ selected_crypto_option.label }} Payment Instructions

Send exactly: {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}
Destination wallet:
{{ pending_crypto_payment.wallet_address }}
Reference / Invoice:
{{ pending_crypto_payment.reference }} {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
Open in MetaMask Mobile

Fastest way to pay

1. Click Open MetaMask / Rabby if your wallet is installed in this browser.

2. If that does not open your wallet, click Open in MetaMask Mobile.

3. If needed, use Copy Payment Details and send manually.

You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet.
{% elif pending_crypto_payment.txid %}
Transaction Hash:
{{ pending_crypto_payment.txid }}
Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.
{% elif pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update
{% endif %}
{% else %}
{% for q in crypto_options %} {% endfor %}
AssetQuoted AmountCAD PriceStatusAction
{{ q.label }} {% if q.recommended %}recommended{% endif %} {% if q.wallet_capable %}wallet{% endif %} {{ q.display_amount or "—" }} {% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} {% if q.available %}live{% else %}{{ q.reason or "unavailable" }}{% endif %}
{% endif %}
{% else %}

No crypto quote snapshot is available for this invoice yet.

{% endif %}
{% endif %} {% if invoice_payments %}

Payments Applied

{% for p in invoice_payments %} {% endfor %}
Method Amount Status Received Reference / TXID
{{ p.payment_method_label }} {{ p.payment_amount_display }} {{ p.payment_currency }} {{ p.payment_status }} {{ p.received_at_local }} {% if p.txid %} {{ p.txid }} {% elif p.reference %} {{ p.reference }} {% else %} - {% endif %} {% if p.wallet_address %}
{{ p.wallet_address }}{% endif %}
{% endif %} {% if pdf_url %} {% endif %} +
{% include "footer.html" %} + + + +=== /home/def/otb_billing/templates/portal_login.html === + + + Client Portal - OutsideTheBox + + +{% include "includes/site_nav.html" %}

OutsideTheBox Client Portal

Secure access for invoices, balances, and account information.

{% if portal_message %}
{{ portal_message }}
{% endif %}

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.

+
{% include "footer.html" %} + + + +=== /home/def/otb_billing/static/css/style.css === +body { + font-family: Arial; + background: #0f172a; + color: #e5e7eb; +} + +header { + padding: 20px; + background: #111827; +} + +/* === OTB SITE NAV (from outsidethebox.top) === */ + +.container{ + max-width:1100px; + margin:0 auto; + padding:20px 18px; +} + +.nav{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; +} + +.brand{ + display:flex; + align-items:center; + gap:14px; + text-decoration:none; + color:#e5e7eb; +} + +.brand img{ + height:60px; + background: rgba(255,255,255,0.92); + padding: 6px 12px; + border-radius: 999px; +} + +.title{ + display:flex; + flex-direction:column; + line-height:1.1; +} + +.title span{ + color:#9ca3af; + font-size:13px; +} + +.navlinks{ + display:flex; + gap:12px; +} + +.navlinks a{ + text-decoration:none; + padding:6px 10px; + border-radius:10px; + color:#9ca3af; +} + +.navlinks a:hover{ + color:#e5e7eb; + background:rgba(255,255,255,0.05); +} + diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..90dd55d --- /dev/null +++ b/output.txt @@ -0,0 +1,7807 @@ +import os +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 +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 json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +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 + +TERMS_VERSION = "v1.0" + +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") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +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") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +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 payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +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() + 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 + + invoice_currency = str(invoice.get("currency_code") or "CAD").upper() + + if invoice_currency == "CAD": + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = 'CAD' + THEN payment_amount + ELSE COALESCE(cad_value_at_payment, 0) + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + else: + cursor.execute(""" + SELECT COALESCE(SUM( + CASE + WHEN UPPER(COALESCE(payment_currency, '')) = %s + THEN payment_amount + ELSE 0 + END + ), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_currency, 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if session.get("admin_authenticated"): + return redirect("/") + + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if username == OTB_ADMIN_USER and password == OTB_ADMIN_PASS: + session["admin_authenticated"] = True + session["admin_user"] = username + return redirect(session.pop("admin_next", "/")) + + error = "Invalid admin credentials." + + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout", methods=["GET"]) +def admin_logout(): + session.pop("admin_authenticated", None) + session.pop("admin_user", None) + session.pop("admin_next", None) + return redirect("/admin/login") + + +@app.route("/admin", methods=["GET"]) +def admin_index(): + gate = admin_required() + if gate: + return gate + return redirect("/") + + +@app.route("/clients") +def clients(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form["client_id"] + service_name = request.form["service_name"] + service_type = request.form["service_type"] + billing_cycle = request.form["billing_cycle"] + currency_code = request.form["currency_code"] + recurring_amount = request.form["recurring_amount"] + status = request.form["status"] + start_date = request.form["start_date"] or None + description = request.form["description"] + + cursor.execute("SELECT MAX(id) AS last_id FROM services") + result = cursor.fetchone() + last_number = result["last_id"] if result["last_id"] else 0 + service_code = generate_service_code(service_name, last_number) + + insert_cursor = conn.cursor() + insert_cursor.execute( + """ + INSERT INTO services + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + client_id, + service_code, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date, + description + ) + ) + conn.commit() + conn.close() + + return redirect("/services") + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + return render_template("services/new.html", clients=clients) + +@app.route("/services/edit/", methods=["GET", "POST"]) +def edit_service(service_id): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + service_name = request.form.get("service_name", "").strip() + service_type = request.form.get("service_type", "").strip() + billing_cycle = request.form.get("billing_cycle", "").strip() + currency_code = request.form.get("currency_code", "").strip() + recurring_amount = request.form.get("recurring_amount", "").strip() + status = request.form.get("status", "").strip() + start_date = request.form.get("start_date", "").strip() + description = request.form.get("description", "").strip() + + errors = [] + + if not client_id: + errors.append("Client is required.") + if not service_name: + errors.append("Service name is required.") + if not service_type: + errors.append("Service type is required.") + if not billing_cycle: + errors.append("Billing cycle is required.") + if not currency_code: + errors.append("Currency code is required.") + if not recurring_amount: + errors.append("Recurring amount is required.") + if not status: + errors.append("Status is required.") + + if not errors: + try: + recurring_amount_value = float(recurring_amount) + if recurring_amount_value < 0: + errors.append("Recurring amount cannot be negative.") + except ValueError: + errors.append("Recurring amount must be a valid number.") + + if errors: + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + + conn.close() + return render_template("services/edit.html", service=service, clients=clients, errors=errors) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE services + SET client_id = %s, + service_name = %s, + service_type = %s, + billing_cycle = %s, + status = %s, + currency_code = %s, + recurring_amount = %s, + start_date = %s, + description = %s + WHERE id = %s + """, ( + client_id, + service_name, + service_type, + billing_cycle, + status, + currency_code, + recurring_amount, + start_date or None, + description or None, + service_id + )) + conn.commit() + conn.close() + return redirect("/services") + + cursor.execute(""" + SELECT s.*, c.company_name + FROM services s + LEFT JOIN clients c ON s.client_id = c.id + WHERE s.id = %s + """, (service_id,)) + service = cursor.fetchone() + + cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") + clients = cursor.fetchall() + conn.close() + + if not service: + return "Service not found", 404 + + return render_template("services/edit.html", service=service, clients=clients, errors=[]) + + + + + + +@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(): + gate = admin_required() + if gate: + return gate + 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, + } + for inv in invoices: + inv["paid_via"] = "-" + + if str(inv.get("status") or "").lower() not in {"paid", "partial"}: + continue + + pay_conn = get_db_connection() + pay_cursor = pay_conn.cursor(dictionary=True) + pay_cursor.execute(""" + SELECT payment_method, payment_currency + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + ORDER BY COALESCE(received_at, created_at) DESC, id DESC + LIMIT 1 + """, (inv["id"],)) + last_payment = pay_cursor.fetchone() + pay_conn.close() + + if last_payment: + inv["paid_via"] = payment_method_label( + last_payment.get("payment_method"), + last_payment.get("payment_currency"), + ) + + + + return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) + +@app.route("/invoices/new", methods=["GET", "POST"]) +def new_invoice(): + gate = admin_required() + if gate: + return gate + ensure_invoice_quote_columns() + 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}" + + oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) + oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None + quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) + quote_fiat_amount = total_amount if oracle_snapshot else None + quote_fiat_currency = currency_code if oracle_snapshot else None + + 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, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot + ) + VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) + """, ( + client_id, + service_id, + invoice_number, + currency_code, + total_amount, + total_amount, + due_at, + notes, + quote_fiat_amount, + quote_fiat_currency, + quote_expires_at, + oracle_snapshot_json + )) + + 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 invoice_payments: + y -= 8 + if y < 170: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 11) + pdf.drawString(left, y, "Payments Applied") + y -= 16 + + for p in invoice_payments: + if y < 110: + pdf.showPage() + y = height - 50 + + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString( + left, + y, + f"{p.get('payment_method_label', 'Unknown')} | {p.get('payment_amount_display', '')} {p.get('payment_currency', '')} | {str(p.get('payment_status') or '').upper()}" + ) + y -= 13 + + details_parts = [] + if p.get("received_at_local"): + details_parts.append(f"At: {p.get('received_at_local')}") + if p.get("txid"): + details_parts.append(f"TXID: {p.get('txid')}") + elif p.get("reference"): + details_parts.append(f"Ref: {p.get('reference')}") + if p.get("wallet_address"): + details_parts.append(f"Wallet: {p.get('wallet_address')}") + + details = " | ".join(details_parts) + if details: + pdf.setFont("Helvetica", 9) + for chunk_start in range(0, len(details), 108): + if y < 95: + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica", 9) + pdf.drawString(left + 10, y, details[chunk_start:chunk_start+108]) + y -= 11 + + y -= 6 + + 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() + invoice_payments = get_invoice_payments(invoice_id) + return render_template( + "invoices/view.html", + invoice=invoice, + settings=settings, + invoice_payments=invoice_payments + ) + + +@app.route("/invoices/edit/", methods=["GET", "POST"]) +def edit_invoice(invoice_id): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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(): + gate = admin_required() + if gate: + return gate + 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) + + 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(""" + 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): + gate = admin_required() + if gate: + return gate + 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_terms_required(client): + if not client: + return False + accepted_at = client.get("terms_accepted_at") + accepted_version = (client.get("terms_version") or "").strip() + return (not accepted_at) or (accepted_version != TERMS_VERSION) + +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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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, + terms_accepted_at, terms_accepted_ip, terms_accepted_user_agent, terms_version + 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") + + if portal_terms_required(client): + return redirect("/portal/terms") + + 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/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() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.created_at, + i.total_amount, + i.amount_paid, + p.payment_method, + p.payment_currency + FROM invoices i + LEFT JOIN ( + SELECT p1.invoice_id, p1.payment_method, p1.payment_currency + FROM payments p1 + INNER JOIN ( + SELECT invoice_id, MAX(id) AS max_id + FROM payments + GROUP BY invoice_id + ) latest + ON latest.invoice_id = p1.invoice_id + AND latest.max_id = p1.id + ) p + ON p.invoice_id = i.id + WHERE i.client_id = %s + ORDER BY i.created_at DESC + """, (client["id"],)) + invoices = cursor.fetchall() + + def _fmt_money(value): + return f"{to_decimal(value):.2f}" + + def _payment_method_label(method, currency): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + if method_key == "other" and currency_key: + return currency_key + if currency_key: + return currency_key + return "" + + for row in invoices: + outstanding_raw = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + row["payment_method_label"] = _payment_method_label( + row.get("payment_method"), + row.get("payment_currency"), + ) + + 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")) + client_credit_balance = get_client_credit_balance(client["id"]) + + 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}", + client_credit_balance=f"{client_credit_balance:.2f}", + ) + + +@app.route("/portal/invoice//pdf", methods=["GET"]) +def portal_invoice_pdf(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + 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 AND i.client_id = %s + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return redirect("/portal/dashboard") + + 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("/portal/invoice/", methods=["GET"]) +def portal_invoice_detail(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + if portal_terms_required(client): + return redirect("/portal/terms") + + ensure_invoice_quote_columns() + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + 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_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + 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")) + invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + 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")) + + pay_mode = (request.args.get("pay") or "").strip().lower() + crypto_error = (request.args.get("crypto_error") or "").strip() + + crypto_options = get_invoice_crypto_options(invoice) + selected_crypto_option = None + pending_crypto_payment = None + crypto_quote_window_expires_iso = None + crypto_quote_window_expires_local = None + + if (invoice.get("status") or "").lower() != "paid": + if pay_mode != "crypto": + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"])) + auto_pending_payment = cursor.fetchone() + if auto_pending_payment: + pending_crypto_payment = auto_pending_payment + pay_mode = "crypto" + if not request.args.get("payment_id"): + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(auto_pending_payment.get("payment_currency") or "").upper()), + None + ) + + if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid": + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + quote_start_dt = now_utc + session[quote_key] = quote_start_dt.isoformat() + + quote_expires_dt = quote_start_dt + timedelta(seconds=90) + crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat() + crypto_quote_window_expires_local = fmt_local(quote_expires_dt) + + selected_asset = (request.args.get("asset") or "").strip().upper() + if selected_asset: + selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None) + + payment_id = (request.args.get("payment_id") or "").strip() + if not payment_id and pending_crypto_payment: + payment_id = str(pending_crypto_payment.get("id") or "").strip() + + if payment_id.isdigit(): + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, received_at, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + pending_crypto_payment = cursor.fetchone() + + if pending_crypto_payment: + created_dt = pending_crypto_payment.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt: + lock_expires_dt = created_dt + timedelta(minutes=2) + pending_crypto_payment["created_at_local"] = fmt_local(created_dt) + pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt) + pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt + else: + pending_crypto_payment["created_at_local"] = "" + pending_crypto_payment["lock_expires_at_local"] = "" + pending_crypto_payment["lock_expires_at_iso"] = "" + pending_crypto_payment["lock_expired"] = True + + received_dt = pending_crypto_payment.get("received_at") + if received_dt and received_dt.tzinfo is None: + received_dt = received_dt.replace(tzinfo=timezone.utc) + + if received_dt: + processing_expires_dt = received_dt + timedelta(minutes=15) + pending_crypto_payment["received_at_local"] = fmt_local(received_dt) + pending_crypto_payment["processing_expires_at_local"] = fmt_local(processing_expires_dt) + pending_crypto_payment["processing_expires_at_iso"] = processing_expires_dt.astimezone(timezone.utc).isoformat() + pending_crypto_payment["processing_expired"] = datetime.now(timezone.utc) >= processing_expires_dt + else: + pending_crypto_payment["received_at_local"] = "" + pending_crypto_payment["processing_expires_at_local"] = "" + pending_crypto_payment["processing_expires_at_iso"] = "" + pending_crypto_payment["processing_expired"] = False + + if not selected_crypto_option: + selected_crypto_option = next( + (o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()), + None + ) + + if pending_crypto_payment.get("txid") and str(pending_crypto_payment.get("payment_status") or "").lower() == "pending": + reconcile_result = reconcile_pending_crypto_payment(pending_crypto_payment) + + cursor.execute(""" + SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + refreshed_invoice = cursor.fetchone() + if refreshed_invoice: + invoice.update(refreshed_invoice) + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment, + reference, wallet_address, payment_status, created_at, updated_at, txid, confirmations, + confirmation_required, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (payment_id, invoice_id, client["id"])) + refreshed_payment = cursor.fetchone() + if refreshed_payment: + pending_crypto_payment = refreshed_payment + + if reconcile_result.get("state") == "confirmed": + outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) + outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0") + invoice["outstanding"] = _fmt_money(outstanding) + invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) + invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) + elif reconcile_result.get("state") in {"timeout", "failed_receipt"}: + crypto_error = "Transaction was not confirmed in time. Please refresh your quote and try again." + + pdf_url = f"/invoices/pdf/{invoice_id}" + invoice_payments = get_invoice_payments(invoice_id) + + conn.close() + + return render_template( + "portal_invoice_detail.html", + client=client, + invoice=invoice, + items=items, + pdf_url=pdf_url, + pay_mode=pay_mode, + crypto_error=crypto_error, + crypto_options=crypto_options, + selected_crypto_option=selected_crypto_option, + pending_crypto_payment=pending_crypto_payment, + crypto_quote_window_expires_iso=crypto_quote_window_expires_iso, + crypto_quote_window_expires_local=crypto_quote_window_expires_local, + invoice_payments=invoice_payments, + ) + + + +@app.route("/portal/terms", methods=["GET", "POST"]) +def portal_terms(): + client = _portal_current_client() + if not client: + return redirect("/portal") + + if request.method == "POST": + accepted = request.form.get("accept_terms") == "yes" + if not accepted: + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error="You must confirm that you have read and understood the agreement." + ) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE clients + SET terms_accepted_at = NOW(), + terms_accepted_ip = %s, + terms_accepted_user_agent = %s, + terms_version = %s + WHERE id = %s + """, ( + request.headers.get("X-Forwarded-For", request.remote_addr or "")[:64], + (request.headers.get("User-Agent") or "")[:1000], + TERMS_VERSION, + client["id"] + )) + conn.commit() + conn.close() + + return redirect("/portal/dashboard") + + return render_template( + "portal_terms.html", + client=client, + terms_version=TERMS_VERSION, + error=None + ) + + +@app.route("/portal/logout", methods=["GET"]) +def portal_logout(): + session.pop("portal_client_id", None) + session.pop("portal_email", None) + return redirect("/portal") + + + +@app.route("/clients/portal/enable/", methods=["POST"]) +def client_portal_enable(client_id): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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): + gate = admin_required() + if gate: + return gate + 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-crypto", methods=["POST"]) +def portal_invoice_pay_crypto(invoice_id): + client = _portal_current_client() + if not client: + return redirect("/portal") + + ensure_invoice_quote_columns() + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, client_id, invoice_number, status, total_amount, amount_paid, + quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, + created_at + 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") + + status = (invoice.get("status") or "").lower() + if status == "paid": + conn.close() + return redirect(f"/portal/invoice/{invoice_id}") + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + options = get_invoice_crypto_options(invoice) + chosen_symbol = (request.form.get("asset") or "").strip().upper() + selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) + + quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" + now_utc = datetime.now(timezone.utc) + stored_start = session.get(quote_key) + quote_start_dt = None + if stored_start: + try: + quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) + if quote_start_dt.tzinfo is None: + quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) + except Exception: + quote_start_dt = None + + if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: + session.pop(quote_key, None) + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") + + if not selected_option: + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") + + if not selected_option.get("available"): + conn.close() + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") + + cursor.execute(""" + SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes + FROM payments + WHERE invoice_id = %s + AND client_id = %s + AND payment_status = 'pending' + AND payment_currency = %s + ORDER BY id DESC + LIMIT 1 + """, (invoice_id, client["id"], selected_option["payment_currency"])) + existing = cursor.fetchone() + + pending_payment_id = None + + if existing: + created_dt = existing.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + + if created_dt and (now_utc - created_dt).total_seconds() <= 120: + pending_payment_id = existing["id"] + + if not pending_payment_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, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) + """, ( + invoice["id"], + invoice["client_id"], + selected_option["payment_currency"], + str(selected_option["display_amount"]), + str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), + invoice["invoice_number"], + client.get("email") or client.get("company_name") or "Portal Client", + selected_option["wallet_address"], + f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" + )) + conn.commit() + pending_payment_id = insert_cursor.lastrowid + + session.pop(quote_key, None) + conn.close() + + return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") + +@app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) +def portal_submit_crypto_tx(invoice_id): + client = _portal_current_client() + if not client: + return jsonify({"ok": False, "error": "not_authenticated"}), 401 + + ensure_invoice_quote_columns() + + try: + payload = request.get_json(force=True) or {} + except Exception: + payload = {} + + payment_id = str(payload.get("payment_id") or "").strip() + asset = str(payload.get("asset") or "").strip().upper() + tx_hash = str(payload.get("tx_hash") or "").strip() + + if not payment_id.isdigit(): + return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 + if not asset: + return jsonify({"ok": False, "error": "missing_asset"}), 400 + if not tx_hash or not tx_hash.startswith("0x"): + return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT id, client_id, invoice_number, oracle_snapshot + FROM invoices + WHERE id = %s AND client_id = %s + LIMIT 1 + """, (invoice_id, client["id"])) + invoice = cursor.fetchone() + + if not invoice: + conn.close() + return jsonify({"ok": False, "error": "invoice_not_found"}), 404 + + invoice["oracle_quote"] = None + if invoice.get("oracle_snapshot"): + try: + invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) + except Exception: + invoice["oracle_quote"] = None + + crypto_options = get_invoice_crypto_options(invoice) + selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) + + if not selected_option: + conn.close() + return jsonify({"ok": False, "error": "invalid_asset"}), 400 + + cursor.execute(""" + SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes + FROM payments + WHERE id = %s + AND invoice_id = %s + AND client_id = %s + LIMIT 1 + """, (int(payment_id), invoice_id, client["id"])) + payment = cursor.fetchone() + + if not payment: + conn.close() + return jsonify({"ok": False, "error": "payment_not_found"}), 404 + + if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: + conn.close() + return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 + + if str(payment.get("payment_status") or "").lower() != "pending": + conn.close() + return jsonify({"ok": False, "error": "payment_not_pending"}), 400 + + new_notes = append_payment_note( + payment.get("notes"), + f"[portal wallet submit] tx hash accepted: {tx_hash}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET txid = %s, + payment_status = 'pending', + notes = %s + WHERE id = %s + """, ( + tx_hash, + new_notes, + payment["id"] + )) + conn.commit() + conn.close() + + return jsonify({ + "ok": True, + "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" + }) + + +@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(): + gate = admin_required() + if gate: + return gate + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + payment_status, + received_at + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY received_at DESC + """) + payments = cursor.fetchall() + conn.close() + + now_local = datetime.now(LOCAL_TZ) + today_str = now_local.strftime("%Y-%m-%d") + month_prefix = now_local.strftime("%Y-%m") + year_prefix = now_local.strftime("%Y") + + categories = [ + ("cash", "Cash"), + ("etransfer", "eTransfer"), + ("square", "Square"), + ("etho", "ETHO"), + ("eti", "ETI"), + ("egaz", "EGAZ"), + ("eth", "ETH"), + ("other", "Other"), + ] + + periods = { + "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, + } + + def norm_method(method): + m = (method or "").strip().lower() + if m in ("cash",): + return "cash" + if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): + return "etransfer" + if m in ("square",): + return "square" + if m in ("etho",): + return "etho" + if m in ("eti",): + return "eti" + if m in ("egaz",): + return "egaz" + if m in ("eth", "ethereum"): + return "eth" + return "other" + + for p in payments: + received = p.get("received_at") + if not received: + continue + + if isinstance(received, str): + received_local_str = received[:10] + received_month = received[:7] + received_year = received[:4] + else: + if received.tzinfo is None: + received = received.replace(tzinfo=timezone.utc) + received_local = received.astimezone(LOCAL_TZ) + received_local_str = received_local.strftime("%Y-%m-%d") + received_month = received_local.strftime("%Y-%m") + received_year = received_local.strftime("%Y") + + bucket = norm_method(p.get("payment_method")) + amount = to_decimal(p.get("cad_value_at_payment") or p.get("payment_amount") or "0") + + if received_year == year_prefix: + periods["ytd"]["totals"][bucket] += amount + periods["ytd"]["grand"] += amount + + if received_month == month_prefix: + periods["month"]["totals"][bucket] += amount + periods["month"]["grand"] += amount + + if received_local_str == today_str: + periods["today"]["totals"][bucket] += amount + periods["today"]["grand"] += amount + + period_cards = [] + for key in ("today", "month", "ytd"): + block = periods[key] + lines = [] + for cat_key, cat_label in categories: + lines.append(f"{cat_label}{block['totals'][cat_key]:.2f}") + period_cards.append(f""" +
+

{block['label']}

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

Accountbook

+

Confirmed payment totals by period and payment type.

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

Square Reconciliation

+

Recent Square webhook events and auto-apply outcomes.

+
+ +
+ +

Log file: {SQUARE_WEBHOOK_LOG}

+

Current Filter: {filter_mode or "all"}

+ + {summary_html} + + + + + + + + + + + + + + + {''.join(rows_html) if rows_html else ''} + +
Logged At (UTC)EventPayment IDAmount (cents)NoteSig ValidAuto Apply Result
No webhook events found.
+
+ +""" + return Response(html, mimetype="text/html") + + +@app.route("/square/webhook", methods=["POST"]) +def square_webhook(): + raw_body = request.get_data() + signature_header = request.headers.get("x-square-hmacsha256-signature", "") + notification_url = SQUARE_WEBHOOK_NOTIFICATION_URL or request.url + + valid = square_signature_is_valid(signature_header, raw_body, notification_url) + + parsed = None + try: + parsed = json.loads(raw_body.decode("utf-8")) + except Exception: + parsed = None + + event_id = None + event_type = None + payment_id = None + payment_status = None + amount_money = None + reference_id = None + note = None + order_id = None + customer_id = None + receipt_number = None + source_type = None + + try: + if isinstance(parsed, dict): + event_id = parsed.get("event_id") + event_type = parsed.get("type") + data_obj = (((parsed.get("data") or {}).get("object")) or {}) + payment = data_obj.get("payment") or {} + payment_id = payment.get("id") + payment_status = payment.get("status") + amount_money = (((payment.get("amount_money") or {}).get("amount"))) + reference_id = payment.get("reference_id") + note = payment.get("note") + order_id = payment.get("order_id") + customer_id = payment.get("customer_id") + receipt_number = payment.get("receipt_number") + source_type = ((payment.get("source_type")) or "") + except Exception: + pass + + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "signature_valid": valid, + "event_id": event_id, + "event_type": event_type, + "payment_id": payment_id, + "payment_status": payment_status, + "amount_money": amount_money, + "reference_id": reference_id, + "note": note, + "order_id": order_id, + "customer_id": customer_id, + "receipt_number": receipt_number, + "source_type": source_type, + "headers": { + "x-square-hmacsha256-signature": bool(signature_header), + "content-type": request.headers.get("content-type", ""), + "user-agent": request.headers.get("user-agent", ""), + }, + "raw_json": parsed, + }) + + if not valid: + return jsonify({"ok": False, "error": "invalid signature"}), 403 + + result = auto_apply_square_payment(parsed or {}) + append_square_webhook_log({ + "logged_at_utc": datetime.utcnow().isoformat() + "Z", + "auto_apply_result": result, + "source": "square_webhook_postprocess" + }) + + return jsonify({"ok": True, "result": result}), 200 + +register_health_routes(app) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) + + + + + OTB Billing Admin Login + + + + + {% include "includes/site_nav.html" %} +
+
+
+

OTB Billing Admin Login

+

Authorized administrative access only.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + Client Portal +
+
+
+
+
+ {% include "footer.html" %} + + + + + + + +{{ page_title }} + + + + + + +
+

OTB Billing

+
+ +
+

{{ content }}

+
+ + + +{% include "footer.html" %} + + + +OTB Billing Dashboard + + + + + +
+ {% if app_settings.business_logo_url %} +
+ Logo +
+ {% endif %} + +

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

+ +

+ Admin | + Admin Logout | + Client Portal +

+ + {% if request.args.get('pkg_email') == '1' %} +
+ Accounting package emailed successfully. +
+ {% endif %} + + {% if request.args.get('pkg_email_failed') == '1' %} +
+ Accounting package email failed. Check SMTP settings or server log. +
+ {% endif %} + + + +
+ +
+ + + + + + + + + + + + + + + + +
Total ClientsActive ServicesOutstanding InvoicesOutstanding Balance (CAD)Revenue Received (CAD)
{{ total_clients }}{{ active_services }}{{ outstanding_invoices }}{{ outstanding_balance|money('CAD') }}{{ revenue_received|money('CAD') }}
+ +

Displayed times are shown in Eastern Time (Toronto).

+
+ +{% include "footer.html" %} + + + +
+ + + + + + + + + System Health - OTB Billing + + + + + +
+ + + + +
+
+

Status

+ {% if health.status == "ok" %} +

Healthy

+ {% else %} +

Degraded

+ {% endif %} +

App: {{ health.app_name }}

+

Host: {{ health.hostname }}

+

Toronto Time: {{ health.server_time_toronto }}

+

UTC Time: {{ health.server_time_utc }}

+
+ +
+

Database

+ {% if health.database.ok %} +

Connected

+ {% else %} +

Connection Error

+ {% endif %} +

Error: {{ health.database.error or "None" }}

+
+ +
+

Uptime

+

Application: {{ health.app_uptime_human }}

+

Server: {{ health.server_uptime_human }}

+
+ +
+

Load Average

+

1 min: {{ health.load_average["1m"] }}

+

5 min: {{ health.load_average["5m"] }}

+

15 min: {{ health.load_average["15m"] }}

+
+ +
+

Memory

+

Total: {{ health.memory.total_mb }} MB

+

Available: {{ health.memory.available_mb }} MB

+

Used: {{ health.memory.used_mb }} MB

+

Used %: {{ health.memory.used_percent }}%

+
+ +
+

Disk /

+

Total: {{ health.disk_root.total_gb }} GB

+

Used: {{ health.disk_root.used_gb }} GB

+

Free: {{ health.disk_root.free_gb }} GB

+

Used %: {{ health.disk_root.used_percent }}%

+
+
+
+ +{% include "footer.html" %} + + + + + + + + Client Dashboard - OutsideTheBox + + + + + {% include "includes/site_nav.html" %} + +
+
+
+
+

Client Dashboard

+

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

+

Invoices, balances, and account activity in one place.

+
+ +
+ +
+
Logged in as: {{ client.contact_name or client.company_name or client.email }}
+ {% if client_credit_balance and client_credit_balance != "0.00" %} +
+ + 🏦 Credit: ${{ client_credit_balance }} + +
+ {% endif %} +
+
+
+ +
+
+

Total Invoices

+
{{ invoice_count }}
+
Invoices currently visible in your portal
+
+ +
+

Total Outstanding

+
{{ total_outstanding }}
+
Current unpaid balance
+
+ +
+

Total Paid

+
{{ total_paid }}
+
Payments already applied
+
+
+ +

Invoices

+ +
+ + + + + + + + + + + + + {% for row in invoices %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
InvoiceStatusCreatedTotalPaidOutstanding
+ + {{ row.invoice_number or ("INV-" ~ row.id) }} + + + {% set s = (row.status or "")|lower %} + {% if s == "paid" %} + {{ row.status }} + {% if row.payment_method_label %} +
+ {{ row.payment_method_label }} +
+ {% endif %} + {% elif s == "pending" %} + {{ row.status }} + {% elif s == "overdue" %} + {{ row.status }} + {% else %} + {{ row.status }} + {% endif %} +
{{ row.created_at }}{{ row.total_amount }}{{ row.amount_paid }}{{ row.outstanding }}
No invoices available.
+
+
+
+ + + + +
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + + Methods: + Credit Card (via Square), + e-Transfer, + and enabled crypto assets + +
+
+ + + + + + + Forgot Portal Password - OutsideTheBox + + +{% include "includes/site_nav.html" %}

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 %}
+
+
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + + Methods: + Credit Card (via Square), + e-Transfer, + and enabled crypto assets + +
+
+ + + + + + + Invoice Detail - OutsideTheBox + + +{% include "includes/site_nav.html" %}

Invoice Detail

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

{% if (invoice.status or "")|lower == "paid" %}
✓ This invoice has been paid. Thank you!
{% endif %} {% if crypto_error %}
{{ crypto_error }}
{% endif %}

Invoice

{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Status

{% set s = (invoice.status or "")|lower %} {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} processing {% elif s == "paid" %}{{ invoice.status }} {% elif s == "pending" %}{{ invoice.status }} {% elif s == "overdue" %}{{ invoice.status }} {% else %}{{ invoice.status }}{% endif %}

Created

{{ invoice.created_at }}

Total

{{ invoice.total_amount }}

Paid

{{ invoice.amount_paid }}

Outstanding

{{ invoice.outstanding }}

Invoice Items

{% for item in items %} {% else %} {% endfor %}
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %}

Pay Now

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

Credit Card (Square)

Pay with Credit Card
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %}

Crypto Quote Snapshot

Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
{% if pending_crypto_payment %}
Your quote is protected after acceptance.
{% else %}
Select a crypto asset to accept the quote.
{% endif %}
{% if pending_crypto_payment and pending_crypto_payment.txid %}
--:--
Watching transaction / waiting for confirmation
{% elif pending_crypto_payment %}
--:--
Quote protected while you open wallet
{% else %}
--:--
This price times out:
{% endif %}
{% if pending_crypto_payment and selected_crypto_option %}

{{ selected_crypto_option.label }} Payment Instructions

Send exactly: {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}
Destination wallet:
{{ pending_crypto_payment.wallet_address }}
Reference / Invoice:
{{ pending_crypto_payment.reference }} {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
Open in MetaMask Mobile

Fastest way to pay

1. Click Open MetaMask / Rabby if your wallet is installed in this browser.

2. If that does not open your wallet, click Open in MetaMask Mobile.

3. If needed, use Copy Payment Details and send manually.

You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet.
{% elif pending_crypto_payment.txid %}
Transaction Hash:
{{ pending_crypto_payment.txid }}
Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.
{% elif pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update
{% endif %}
{% else %}
{% for q in crypto_options %} {% endfor %}
AssetQuoted AmountCAD PriceStatusAction
{{ q.label }} {% if q.recommended %}recommended{% endif %} {% if q.wallet_capable %}wallet{% endif %} {{ q.display_amount or "—" }} {% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} {% if q.available %}live{% else %}{{ q.reason or "unavailable" }}{% endif %}
{% endif %}
{% else %}

No crypto quote snapshot is available for this invoice yet.

{% endif %}
{% endif %} {% if invoice_payments %}

Payments Applied

{% for p in invoice_payments %} {% endfor %}
Method Amount Status Received Reference / TXID
{{ p.payment_method_label }} {{ p.payment_amount_display }} {{ p.payment_currency }} {{ p.payment_status }} {{ p.received_at_local }} {% if p.txid %} {{ p.txid }} {% elif p.reference %} {{ p.reference }} {% else %} - {% endif %} {% if p.wallet_address %}
{{ p.wallet_address }}{% endif %}
{% endif %} {% if pdf_url %} {% endif %} +
+
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + + Methods: + Credit Card (via Square), + e-Transfer, + and enabled crypto assets + +
+
+ + + + + + + + + + Client Portal - OutsideTheBox + + + + + {% include "includes/site_nav.html" %} + +
+
+

OutsideTheBox Client Portal

+

Secure access for invoices, balances, and account information.

+ + {% if portal_message %} +
{{ portal_message }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + Customer Support +
+
+ + + +

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

+
+
+ + +
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + + Methods: + Credit Card (via Square), + e-Transfer, + and enabled crypto assets + +
+
+ + + + + + + Set Portal Password - OutsideTheBox + + +{% include "includes/site_nav.html" %}

Create Your Portal Password

Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.

{% if portal_message %}
{{ portal_message }}
{% endif %}
+
+
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + + Methods: + Credit Card (via Square), + e-Transfer, + and enabled crypto assets + +
+
+ + + + + + + + + + Service Agreement - OutsideTheBox + + + + + {% include "includes/site_nav.html" %} + +
+
+
+
+

Service Agreement

+

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

+

You must read and accept this agreement before using the portal.

+
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+

Outsidethebox.top Service Agreement ({{ terms_version }})

+ +

1. Nature of Service (Follow-Me)

+

Follow-Me is a GPS tracking application.

+

This is a tracking app.
This is a tracking app.
This is a tracking app.

+

Follow-Me is based on a commercial fleet tracking system adapted for public use as a try-before-you-buy service.

+

By using Follow-Me, you acknowledge that location data is collected, transmitted, and may be viewed in real time or historically by authorized users within the same network.

+ +

2. User Consent & Location Data

+

By installing, accessing, or using this service, you explicitly consent to the collection, storage, processing, and display of location data as required for the service to function.

+ +

3. Network Visibility

+

Follow-Me operates on shared networks. Members of a network may view devices within that network. Outsidethebox.top administrative staff may access network data for system maintenance, troubleshooting, and abuse prevention. Administrative access is restricted to authorized personnel only.

+ +

4. Payments & Credit Policy

+

All services are prepaid. Setup fees are paid in advance. Credits are stored in your account ledger and may be applied toward invoices and balances. All payments are non-refundable. Outsidethebox.top may apply available credit toward outstanding balances, and all credit use is recorded in account history.

+ +

5. Data & Privacy

+

Outsidethebox.top does not sell or share your data with third parties.

+

By using this service, you consent to the collection and use of location data as described above.

+

Data is used only for service functionality, system operations, security, and abuse prevention.

+ +

6. Acceptable Use

+

This is a lawful service. Unauthorized tracking, stalking, harassment, predatory use, or any use that violates privacy laws is strictly prohibited. Violations may result in immediate service termination and denial of future use.

+ +

7. User Responsibility

+

You are responsible for obtaining all required consent from any person or device being tracked and for complying with all applicable laws.

+ +

8. Service Intent

+

Outsidethebox.top services are intended for legitimate personal use, business and fleet tracking, and public safety applications.

+
+ +
+ + +
+ + Logout +
+
+
+
+
+ + + +
+
+ All billing is calculated in 🇨🇦 CAD + + Crypto conversions use the OTB Oracle + + Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets +
+
+ + + + + + + +Settings + + + + + +

Settings / Config

+ +

Home

+ +
+
+
+

Business Identity

+ + Business Name
+
+ + Business Logo URL
+
+ Example: /static/favicon.png or https://site.com/logo.png
+ + {% if settings.business_logo_url %} +
+ Business Logo Preview +
+ {% endif %} + + Slogan / Tagline
+
+ + Business Email
+
+ + Business Phone
+
+ + Business Address
+
+ + Website
+
+ + Business Number / Registration Number
+
+ + Default Currency
+ + + Report Frequency
+ +
+ +
+

Tax Settings

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

Advanced / Email / SMTP

+ + SMTP Host
+
+ + SMTP Port
+
+ + SMTP Username
+
+ + SMTP Password
+
+ + From Email
+
+ + From Name
+
+ + Report / Accounting Delivery Email
+
+ +
+ +
+ +
+ +
+
+ +
+

Notes

+

+ Branding, tax identity, and SMTP values are stored here for this installation. +

+

+ Logo can be a local static path like /static/favicon.png or a full external/IPFS URL. +

+

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

+
+
+ +
+ +
+
+ +{% include "footer.html" %} + + diff --git a/scripts/crypto_reconciliation_worker.py b/scripts/crypto_reconciliation_worker.py new file mode 100755 index 0000000..3737960 --- /dev/null +++ b/scripts/crypto_reconciliation_worker.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import smtplib +import traceback +from decimal import Decimal, InvalidOperation +from datetime import datetime, timedelta, timezone +from email.message import EmailMessage +from pathlib import Path + +import mysql.connector +from dotenv import load_dotenv + +BASE_DIR = Path("/home/def/otb_billing") +load_dotenv(BASE_DIR / ".env") + +LOG_PATH = BASE_DIR / "logs" / "crypto_reconciliation_worker.log" +TZ_UTC = timezone.utc + +URGENT_TO = [ + "natural_gas_fitter@yahoo.ca", + "support@outsidethebox.top", +] + +def log(msg: str) -> None: + stamp = datetime.now(TZ_UTC).isoformat() + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a", encoding="utf-8") as fh: + fh.write(f"[{stamp}] {msg}\n") + print(msg) + +def env(name: str, default: str = "") -> str: + return os.getenv(name, default).strip() + +DB_CONFIG = { + "host": env("OTB_BILLING_DB_HOST", "127.0.0.1"), + "port": int(env("OTB_BILLING_DB_PORT", "3306")), + "user": env("OTB_BILLING_DB_USER", "otb_billing"), + "password": env("OTB_BILLING_DB_PASSWORD", ""), + "database": env("OTB_BILLING_DB_NAME", "otb_billing"), +} + +SMTP_CONFIG = { + "host": env("SMTP_HOST"), + "port": int(env("SMTP_PORT", "587") or "587"), + "user": env("SMTP_USER"), + "password": env("SMTP_PASS"), + "from_email": env("SMTP_FROM_EMAIL", env("BUSINESS_EMAIL")), + "from_name": env("SMTP_FROM_NAME", env("BUSINESS_NAME", "OTB Billing")), + "use_tls": env("SMTP_USE_TLS", "1") == "1", + "use_ssl": env("SMTP_USE_SSL", "0") == "1", +} + +RPC_MAP = { + "ETH": [u for u in [env("RPC_ETH_URL"), env("RPC_ETH_URL_2")] if u], + "ETHO": [u for u in [env("RPC_ETHO_URL", "http://62.72.177.111:7545"), env("RPC_ETHO_URL_2", "http://192.168.0.177:6645")] if u], + "ETI": [u for u in [env("RPC_ETICA_URL"), env("RPC_ETICA_URL_2")] if u], + "USDC": [u for u in [env("RPC_ARB_URL"), env("RPC_ARB_URL_2")] if u], +} + +def now_utc(): + return datetime.now(TZ_UTC).replace(tzinfo=None) + +def d(val) -> Decimal: + try: + return Decimal(str(val or "0")) + except (InvalidOperation, ValueError): + return Decimal("0") + +def db(): + return mysql.connector.connect(**DB_CONFIG) + +def send_email(to_list, subject, body, bcc_list=None): + if not SMTP_CONFIG["host"] or not SMTP_CONFIG["from_email"] or not to_list: + log(f"email skipped: subject={subject!r} to={to_list!r}") + return False + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = f'{SMTP_CONFIG["from_name"]} <{SMTP_CONFIG["from_email"]}>' if SMTP_CONFIG["from_name"] else SMTP_CONFIG["from_email"] + msg["To"] = ", ".join(to_list) + if bcc_list: + msg["Bcc"] = ", ".join(bcc_list) + msg.set_content(body) + + if SMTP_CONFIG["use_ssl"]: + with smtplib.SMTP_SSL(SMTP_CONFIG["host"], SMTP_CONFIG["port"], timeout=30) as s: + if SMTP_CONFIG["user"]: + s.login(SMTP_CONFIG["user"], SMTP_CONFIG["password"]) + s.send_message(msg) + else: + with smtplib.SMTP(SMTP_CONFIG["host"], SMTP_CONFIG["port"], timeout=30) as s: + if SMTP_CONFIG["use_tls"]: + s.starttls() + if SMTP_CONFIG["user"]: + s.login(SMTP_CONFIG["user"], SMTP_CONFIG["password"]) + s.send_message(msg) + return True + +def append_note(existing, line): + existing = (existing or "").strip() + if existing: + return existing + "\n" + line + return line + +def _rpc_post(rpc_url, method, params): + import json + import urllib.request + + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return (data or {}).get("result") + + +def _chain_meta(symbol): + symbol = str(symbol or "").upper() + mapping = { + "ETH": { + "asset": "ETH", + "network": "ethereum", + "asset_type": "native", + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "asset": "ETHO", + "network": "etho", + "asset_type": "native", + "decimals": 18, + "token_contract": None, + }, + "ETI": { + "asset": "ETI", + "network": "etica", + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + }, + "USDC": { + "asset": "USDC", + "network": "arbitrum", + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + } + return mapping.get(symbol) + + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text or "0")) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + + return { + "to": to_addr, + "amount": amount_int, + } + + +def fetch_tx_by_hash(rpc_url, txid): + tx = _rpc_post(rpc_url, "eth_getTransactionByHash", [txid]) + if not tx: + return None + + receipt = _rpc_post(rpc_url, "eth_getTransactionReceipt", [txid]) + confirmed = bool(receipt and receipt.get("blockNumber")) + + return { + "tx": tx, + "receipt": receipt, + "confirmed": confirmed, + } + + +def verify_expected_transaction(row, txid): + symbol = str(row.get("payment_currency") or "").upper() + meta = _chain_meta(symbol) + if not meta: + return None + + rpc_urls = RPC_MAP.get(symbol, []) + if not rpc_urls: + return None + + wallet_to = str(row.get("wallet_address") or "").lower() + expected_units = _to_base_units(row.get("payment_amount") or "0", meta["decimals"]) + + for rpc_url in rpc_urls: + try: + found = fetch_tx_by_hash(rpc_url, txid) + if not found: + continue + + tx = found["tx"] + + if meta["asset_type"] == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + + if tx_to != wallet_to: + continue + if tx_value != expected_units: + continue + + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(meta["token_contract"] or "").lower() + if tx_to != contract: + continue + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + continue + + if str(parsed["to"]).lower() != wallet_to: + continue + if int(parsed["amount"]) != expected_units: + continue + + return { + "confirmed": found["confirmed"], + "rpc_url": rpc_url, + "asset": meta["asset"], + "network": meta["network"], + "received_amount_cad": row.get("expected_amount_cad") or row.get("cad_value_at_payment"), + "tx": tx, + "receipt": found["receipt"], + } + except Exception: + continue + + return None + + +def find_cross_asset_match(row, txid): + expected_symbol = str(row.get("payment_currency") or "").upper() + wallet_to = str(row.get("wallet_address") or "").lower() + + for symbol, rpc_urls in RPC_MAP.items(): + if symbol == expected_symbol: + continue + + meta = _chain_meta(symbol) + if not meta: + continue + + for rpc_url in rpc_urls: + try: + found = fetch_tx_by_hash(rpc_url, txid) + if not found: + continue + + tx = found["tx"] + + if meta["asset_type"] == "native": + tx_to = str(tx.get("to") or "").lower() + if tx_to != wallet_to: + continue + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(meta["token_contract"] or "").lower() + if tx_to != contract: + continue + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + continue + if str(parsed["to"]).lower() != wallet_to: + continue + + return { + "confirmed": found["confirmed"], + "txid": txid, + "asset": meta["asset"], + "network": meta["network"], + "rpc_url": rpc_url, + } + except Exception: + continue + + return None + + +def ensure_ledger_credit(conn, invoice_id, client_id, overpayment_cad, payment_id): + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id + FROM credit_ledger + WHERE client_id = %s + AND invoice_id = %s + AND notes LIKE %s + ORDER BY id DESC + LIMIT 1 + """, (client_id, invoice_id, f"%overpayment payment_id={payment_id}%")) + row = cursor.fetchone() + if row: + return row["id"] + + ins = conn.cursor() + ins.execute(""" + INSERT INTO credit_ledger + (client_id, invoice_id, credit_amount, credit_type, notes, created_at) + VALUES (%s, %s, %s, %s, %s, %s) + """, ( + client_id, + invoice_id, + str(overpayment_cad), + "manual_credit", + f"auto overpayment credit payment_id={payment_id}", + now_utc(), + )) + conn.commit() + return ins.lastrowid + +def create_event(conn, payment_id, invoice_id, event_type, details): + cur = conn.cursor() + cur.execute(""" + INSERT INTO payment_reconciliation_events + (payment_id, invoice_id, event_type, event_status, details) + VALUES (%s, %s, %s, 'open', %s) + """, (payment_id, invoice_id, event_type, json.dumps(details, default=str))) + conn.commit() + +def send_client_delay_email(row, milestone): + email = (row.get("client_email") or "").strip() + if not email: + return + invoice_number = row.get("invoice_number") or f"Invoice #{row.get('invoice_id')}" + txid = row.get("txid") or "(not yet captured)" + asset = row.get("payment_currency") or row.get("payment_asset") or "crypto" + body = ( + f"Hello,\n\n" + f"This is a friendly update regarding {invoice_number}.\n\n" + f"Your payment has not fully completed in our system yet. " + f"We are continuing to monitor it and will keep checking for up to 48 hours or until it completes.\n\n" + f"Current checkpoint: {milestone}\n" + f"Recorded transaction ID: {txid}\n" + f"Recorded crypto: {asset}\n\n" + f"If you have it handy, please reply with:\n" + f"- the transaction ID\n" + f"- the sending wallet address (if public)\n" + f"- the crypto/network used\n\n" + f"No action is required right now unless you would like us to verify those details sooner.\n\n" + f"Thank you,\n" + f"OTB Billing" + ) + send_email([email], "Payment update for your invoice", body) + +def send_client_success_after_alert(row): + email = (row.get("client_email") or "").strip() + if not email: + return + invoice_number = row.get("invoice_number") or f"Invoice #{row.get('invoice_id')}" + asset = row.get("payment_currency") or row.get("payment_asset") or "crypto" + txid = row.get("txid") or "(not recorded)" + body = ( + f"Hello,\n\n" + f"Your payment for {invoice_number} has now been confirmed successfully.\n\n" + f"Crypto: {asset}\n" + f"Transaction ID: {txid}\n\n" + f"Thank you,\n" + f"OTB Billing" + ) + send_email([email], "Payment confirmed for your invoice", body) + +def send_client_overpayment_email(row, overpayment_cad): + email = (row.get("client_email") or "").strip() + if not email: + return + invoice_number = row.get("invoice_number") or f"Invoice #{row.get('invoice_id')}" + amount_text = f"{overpayment_cad:.2f} CAD" + body = ( + f"Hello,\n\n" + f"Your last invoice {invoice_number} was overpaid by {amount_text}.\n\n" + f"{amount_text} will be credited to your account.\n\n" + f"If this was mistaken and you would like the over payment sent back, " + f"please email support@outsidethebox.top with the subject:\n" + f"Overpayment return request\n\n" + f"Please include any details in the body. As with other payment options, " + f"the overpayment payback would go back to the address or payment source it came from after review.\n\n" + f"Thank you,\n" + f"OTB Billing" + ) + send_email([email], "Overpayment credit applied to your account", body, bcc_list=URGENT_TO) + +def send_urgent_crosschain(row, detected): + body = ( + f"Invoice: {row.get('invoice_number')}\n" + f"Invoice ID: {row.get('invoice_id')}\n" + f"Payment ID: {row.get('payment_id')}\n" + f"Transaction ID: {detected.get('txid') or row.get('txid')}\n" + f"Expected asset: {row.get('payment_currency')}\n" + f"Detected asset: {detected.get('asset')}\n" + f"Expected network: {row.get('payment_network')}\n" + f"Detected network: {detected.get('network')}\n" + ) + send_email(URGENT_TO, "URGENT crosschain transaction needs resolve immedately!", body) + +def send_urgent_overpayment(row, overpayment_cad, received_cad): + body = ( + f"Invoice: {row.get('invoice_number')}\n" + f"Invoice ID: {row.get('invoice_id')}\n" + f"Payment ID: {row.get('payment_id')}\n" + f"Transaction ID: {row.get('txid')}\n" + f"Expected CAD: {d(row.get('expected_amount_cad') or row.get('cad_value_at_payment')):.2f}\n" + f"Received CAD: {received_cad:.2f}\n" + f"Overpayment CAD: {overpayment_cad:.2f}\n" + f"Asset: {row.get('payment_currency')}\n" + f"Network: {row.get('payment_network')}\n" + f"Client: {row.get('client_email') or row.get('company_name') or ''}\n" + ) + send_email(URGENT_TO, "URGENT overpayment detected – review required", body) + +def mark_invoice_paid_via(conn, invoice_id, method, asset, network): + cur = conn.cursor() + cur.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, (method, asset, network, invoice_id)) + conn.commit() + +def recalc_invoice_amount_paid(conn, invoice_id): + cur = conn.cursor(dictionary=True) + cur.execute(""" + SELECT COALESCE(SUM(cad_value_at_payment), 0) AS total_paid + FROM payments + WHERE invoice_id = %s + AND payment_status = 'confirmed' + """, (invoice_id,)) + paid = d((cur.fetchone() or {}).get("total_paid")) + cur.execute("SELECT total_amount FROM invoices WHERE id = %s", (invoice_id,)) + inv = cur.fetchone() or {} + total = d(inv.get("total_amount")) + status = "paid" if paid >= total and total > 0 else "pending" + upd = conn.cursor() + upd.execute(""" + UPDATE invoices + SET amount_paid = %s, + status = %s, + paid_at = CASE WHEN %s = 'paid' AND paid_at IS NULL THEN %s ELSE paid_at END + WHERE id = %s + """, (str(paid), status, status, now_utc(), invoice_id)) + conn.commit() + +def mark_payment_confirmed(conn, row, received_cad): + cur = conn.cursor() + notes = append_note(row.get("notes"), f"[reconciliation] confirmed at {now_utc().isoformat()} received_cad={received_cad}") + cur.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + review_status = 'resolved', + received_amount_cad = %s, + last_checked_at = %s, + notes = %s + WHERE id = %s + """, (str(received_cad), now_utc(), notes, row["payment_id"])) + conn.commit() + mark_invoice_paid_via(conn, row["invoice_id"], row.get("payment_method") or "crypto", row.get("payment_currency"), row.get("payment_network")) + recalc_invoice_amount_paid(conn, row["invoice_id"]) + +def fetch_candidates(conn, daily=False): + cur = conn.cursor(dictionary=True) + if daily: + cur.execute(""" + SELECT + p.id AS payment_id, + p.invoice_id, + p.client_id, + p.payment_method, + p.payment_currency, + p.payment_network, + p.payment_asset, + p.payment_amount, + p.cad_value_at_payment, + p.expected_amount_cad, + p.received_amount_cad, + p.reference, + p.wallet_address, + p.payment_status, + p.review_status, + p.created_at, + p.updated_at, + p.first_seen_at, + p.last_checked_at, + p.alert_24_sent_at, + p.alert_48_sent_at, + p.success_after_alert_sent_at, + p.urgent_alert_sent_at, + p.overpayment_email_sent_at, + p.txid, + p.notes, + i.invoice_number, + i.status AS invoice_status, + i.total_amount AS invoice_total, + c.company_name, + c.contact_name, + c.email AS client_email + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = p.client_id + WHERE ( + p.payment_method = 'crypto' + OR UPPER(p.payment_currency) IN ('ETH','ETHO','ETI','USDC') + ) + AND p.payment_status IN ('pending', 'failed') + ORDER BY p.created_at ASC + """) + else: + cur.execute(""" + SELECT + p.id AS payment_id, + p.invoice_id, + p.client_id, + p.payment_method, + p.payment_currency, + p.payment_network, + p.payment_asset, + p.payment_amount, + p.cad_value_at_payment, + p.expected_amount_cad, + p.received_amount_cad, + p.reference, + p.wallet_address, + p.payment_status, + p.review_status, + p.created_at, + p.updated_at, + p.first_seen_at, + p.last_checked_at, + p.alert_24_sent_at, + p.alert_48_sent_at, + p.success_after_alert_sent_at, + p.urgent_alert_sent_at, + p.overpayment_email_sent_at, + p.txid, + p.notes, + i.invoice_number, + i.status AS invoice_status, + i.total_amount AS invoice_total, + c.company_name, + c.contact_name, + c.email AS client_email + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = p.client_id + WHERE ( + p.payment_method = 'crypto' + OR UPPER(p.payment_currency) IN ('ETH','ETHO','ETI','USDC') + ) + AND p.payment_status IN ('pending', 'failed') + ORDER BY p.created_at ASC + """) + return cur.fetchall() + +def process_one(conn, row): + + payment_id = row["payment_id"] + invoice_id = row["invoice_id"] + expected_cad = d(row.get("expected_amount_cad") or row.get("cad_value_at_payment") or row.get("invoice_total")) + created_at = row.get("created_at") or now_utc() + age = now_utc() - created_at + + cur = conn.cursor() + cur.execute(""" + UPDATE payments + SET first_seen_at = COALESCE(first_seen_at, %s), + last_checked_at = %s + WHERE id = %s + """, (created_at, now_utc(), payment_id)) + conn.commit() + + txid = (row.get("txid") or "").strip() + verified = verify_expected_transaction(row, txid) if txid else None + + if txid and not verified and row.get("payment_status") == "failed": + cur.execute(""" + UPDATE payments + SET payment_status = 'pending', + review_status = 'awaiting_recheck', + review_notes = CONCAT( + COALESCE(review_notes, ''), + %s + ), + last_checked_at = %s + WHERE id = %s + """, ( + "\n[rpc recheck] tx present but not confirmed on configured RPC pool; preserving as pending for retry", + now_utc(), + payment_id + )) + conn.commit() + + if txid and not verified and row.get("payment_status") == "failed": + cur.execute(""" + UPDATE payments + SET payment_status = 'pending', + review_status = 'awaiting_recheck', + review_notes = CONCAT( + COALESCE(review_notes, ''), + %s + ), + last_checked_at = %s + WHERE id = %s + """, ( + "\n[rpc recheck] tx present but not confirmed on configured RPC pool; preserving as pending for retry", + now_utc(), + payment_id + )) + conn.commit() + + if verified and verified.get("confirmed"): + received_cad = d(verified.get("received_amount_cad") or expected_cad) + detected_asset = (verified.get("asset") or row.get("payment_currency") or "").upper() + detected_network = (verified.get("network") or row.get("payment_network") or "").lower() + expected_asset = (row.get("payment_currency") or "").upper() + expected_network = (row.get("payment_network") or "").lower() + + if (detected_asset and expected_asset and detected_asset != expected_asset) or ( + detected_network and expected_network and detected_network != expected_network + ): + if not row.get("urgent_alert_sent_at"): + send_urgent_crosschain(row, verified) + create_event(conn, payment_id, invoice_id, "crosschain_mismatch", { + "expected_asset": expected_asset, + "detected_asset": detected_asset, + "expected_network": expected_network, + "detected_network": detected_network, + "txid": txid, + }) + cur.execute(""" + UPDATE payments + SET review_status = 'crosschain_mismatch', + urgent_alert_sent_at = %s + WHERE id = %s + """, (now_utc(), payment_id)) + conn.commit() + return "flagged" + + mark_payment_confirmed(conn, row, received_cad) + + overpayment_cad = received_cad - expected_cad + if overpayment_cad > Decimal("0"): + ensure_ledger_credit(conn, invoice_id, row["client_id"], overpayment_cad, payment_id) + create_event(conn, payment_id, invoice_id, "overpayment", { + "expected_cad": str(expected_cad), + "received_cad": str(received_cad), + "overpayment_cad": str(overpayment_cad), + }) + if not row.get("urgent_alert_sent_at"): + send_urgent_overpayment(row, overpayment_cad, received_cad) + cur.execute(""" + UPDATE payments + SET urgent_alert_sent_at = %s, + review_status = 'overpayment_pending_review' + WHERE id = %s + """, (now_utc(), payment_id)) + conn.commit() + if overpayment_cad > Decimal("1.00") and not row.get("overpayment_email_sent_at"): + send_client_overpayment_email(row, overpayment_cad) + cur.execute(""" + UPDATE payments + SET overpayment_email_sent_at = %s + WHERE id = %s + """, (now_utc(), payment_id)) + conn.commit() + + if (row.get("alert_24_sent_at") or row.get("alert_48_sent_at")) and not row.get("success_after_alert_sent_at"): + send_client_success_after_alert(row) + cur.execute(""" + UPDATE payments + SET success_after_alert_sent_at = %s + WHERE id = %s + """, (now_utc(), payment_id)) + conn.commit() + + return "resolved" + + cross = find_cross_asset_match(row, txid) + if cross and not row.get("urgent_alert_sent_at"): + send_urgent_crosschain(row, cross) + create_event(conn, payment_id, invoice_id, "crosschain_mismatch", cross) + cur.execute(""" + UPDATE payments + SET review_status = 'crosschain_mismatch', + urgent_alert_sent_at = %s, + last_checked_at = %s + WHERE id = %s + """, (now_utc(), now_utc(), payment_id)) + conn.commit() + return "flagged" + + if age >= timedelta(hours=48) and not row.get("alert_48_sent_at"): + stamp = now_utc() + send_client_delay_email(row, "48 hours") + cur.execute(""" + UPDATE payments + SET alert_48_sent_at = %s, + alert_24_sent_at = COALESCE(alert_24_sent_at, %s), + review_status = 'awaiting_recheck' + WHERE id = %s + """, (stamp, stamp, payment_id)) + conn.commit() + return "notified" + + if age >= timedelta(hours=24) and not row.get("alert_24_sent_at") and not row.get("alert_48_sent_at"): + send_client_delay_email(row, "24 hours") + cur.execute(""" + UPDATE payments + SET alert_24_sent_at = %s, + review_status = 'watching_24h' + WHERE id = %s + """, (now_utc(), payment_id)) + conn.commit() + return "notified" + + return "scanned" + +def main(): + run_mode = "daily" if "--daily" in sys.argv else "interval" + conn = db() + ins = conn.cursor() + ins.execute(""" + INSERT INTO crypto_reconciliation_runs (run_mode, started_at) + VALUES (%s, %s) + """, (run_mode, now_utc())) + conn.commit() + run_id = ins.lastrowid + + scanned = resolved = flagged = 0 + + try: + rows = fetch_candidates(conn, daily=(run_mode == "daily")) + for row in rows: + scanned += 1 + result = process_one(conn, row) + if result == "resolved": + resolved += 1 + elif result == "flagged": + flagged += 1 + + upd = conn.cursor() + upd.execute(""" + UPDATE crypto_reconciliation_runs + SET finished_at = %s, + scanned_count = %s, + resolved_count = %s, + flagged_count = %s, + notes = %s + WHERE id = %s + """, (now_utc(), scanned, resolved, flagged, f"mode={run_mode}", run_id)) + conn.commit() + log(f"crypto reconciliation complete mode={run_mode} scanned={scanned} resolved={resolved} flagged={flagged}") + except Exception as e: + msg = f"crypto reconciliation error mode={run_mode}: {e}\n{traceback.format_exc()}" + log(msg) + upd = conn.cursor() + upd.execute(""" + UPDATE crypto_reconciliation_runs + SET finished_at = %s, + scanned_count = %s, + resolved_count = %s, + flagged_count = %s, + notes = %s + WHERE id = %s + """, (now_utc(), scanned, resolved, flagged, msg[:60000], run_id)) + conn.commit() + raise + finally: + conn.close() + +if __name__ == "__main__": + main() diff --git a/sql/schema_v0.6.0_crypto_reconciliation.sql b/sql/schema_v0.6.0_crypto_reconciliation.sql new file mode 100644 index 0000000..a24ae49 --- /dev/null +++ b/sql/schema_v0.6.0_crypto_reconciliation.sql @@ -0,0 +1,51 @@ +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS txid VARCHAR(255) NULL AFTER reference, + ADD COLUMN IF NOT EXISTS payment_network VARCHAR(64) NULL AFTER payment_currency, + ADD COLUMN IF NOT EXISTS payment_asset VARCHAR(32) NULL AFTER payment_network, + ADD COLUMN IF NOT EXISTS expected_amount_cad DECIMAL(18,8) NULL AFTER cad_value_at_payment, + ADD COLUMN IF NOT EXISTS received_amount_cad DECIMAL(18,8) NULL AFTER expected_amount_cad, + ADD COLUMN IF NOT EXISTS first_seen_at DATETIME NULL AFTER updated_at, + ADD COLUMN IF NOT EXISTS last_checked_at DATETIME NULL AFTER first_seen_at, + ADD COLUMN IF NOT EXISTS alert_24_sent_at DATETIME NULL AFTER last_checked_at, + ADD COLUMN IF NOT EXISTS alert_48_sent_at DATETIME NULL AFTER alert_24_sent_at, + ADD COLUMN IF NOT EXISTS success_after_alert_sent_at DATETIME NULL AFTER alert_48_sent_at, + ADD COLUMN IF NOT EXISTS urgent_alert_sent_at DATETIME NULL AFTER success_after_alert_sent_at, + ADD COLUMN IF NOT EXISTS overpayment_email_sent_at DATETIME NULL AFTER urgent_alert_sent_at, + ADD COLUMN IF NOT EXISTS review_status VARCHAR(64) NOT NULL DEFAULT 'pending' AFTER payment_status, + ADD COLUMN IF NOT EXISTS review_notes TEXT NULL AFTER notes; + +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS paid_via_method VARCHAR(64) NULL AFTER status, + ADD COLUMN IF NOT EXISTS paid_via_asset VARCHAR(32) NULL AFTER paid_via_method, + ADD COLUMN IF NOT EXISTS paid_via_network VARCHAR(64) NULL AFTER paid_via_asset; + +CREATE TABLE IF NOT EXISTS payment_reconciliation_events ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + payment_id INT UNSIGNED NOT NULL, + invoice_id INT UNSIGNED NOT NULL, + event_type VARCHAR(64) NOT NULL, + event_status VARCHAR(64) NOT NULL DEFAULT 'open', + details LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + handled_at DATETIME NULL, + handled_by VARCHAR(128) NULL, + handled_notes TEXT NULL, + KEY idx_pre_payment_id (payment_id), + KEY idx_pre_invoice_id (invoice_id), + KEY idx_pre_event_type (event_type), + KEY idx_pre_event_status (event_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS crypto_reconciliation_runs ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + run_mode VARCHAR(32) NOT NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME NULL, + scanned_count INT NOT NULL DEFAULT 0, + resolved_count INT NOT NULL DEFAULT 0, + flagged_count INT NOT NULL DEFAULT 0, + notes TEXT NULL, + KEY idx_crr_run_mode (run_mode), + KEY idx_crr_started_at (started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/static/brand.js b/static/brand.js new file mode 100644 index 0000000..9b37576 --- /dev/null +++ b/static/brand.js @@ -0,0 +1,44 @@ +(function () { + const STORAGE_KEY = "otb_theme"; + const root = document.documentElement; + + function getPreferredTheme() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === "light" || saved === "dark") return saved; + return "dark"; + } + + function applyTheme(theme) { + root.setAttribute("data-theme", theme); + const toggle = document.getElementById("otbThemeToggle"); + if (toggle) { + toggle.checked = theme === "dark"; + } + } + + function saveTheme(theme) { + localStorage.setItem(STORAGE_KEY, theme); + } + + function initThemeToggle() { + const toggle = document.getElementById("otbThemeToggle"); + if (!toggle) return; + + toggle.addEventListener("change", function () { + const theme = toggle.checked ? "dark" : "light"; + applyTheme(theme); + saveTheme(theme); + }); + } + + function init() { + applyTheme(getPreferredTheme()); + initThemeToggle(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/static/css/style.css b/static/css/style.css index 0a3a803..1e26b43 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,10 +1,1096 @@ -body { - font-family: Arial; - background: #0f172a; - color: #e5e7eb; +:root{ + --bg:#0b0f14; + --card:#121825; + --card-soft:rgba(18,24,37,.78); + --text:#e8eefc; + --muted:#aab6d6; + --line:#24304a; + --accent:#7aa2ff; + --accent2:#62e6b7; + --success:#4ade80; + --warn:#fbbf24; + --danger:#f87171; + --radius:16px; + --shadow:0 16px 40px rgba(0,0,0,.35); } -header { - padding: 20px; - background: #111827; +*{box-sizing:border-box} +html,body{height:100%} + +body{ + margin:0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji"; + background: + radial-gradient(1200px 600px at 20% 0%, rgba(122,162,255,.22), transparent 60%), + radial-gradient(900px 500px at 90% 20%, rgba(98,230,183,.15), transparent 60%), + linear-gradient(180deg, #081225 0%, #09172d 100%); + color:var(--text); + line-height:1.45; +} + +a{color:inherit} + +/* ===== Shared container ===== */ +.container{ + max-width:1100px; + margin:0 auto; + padding:20px 18px; +} + +/* ===== Header / branded nav ===== */ +.header{width:100%} + +.nav{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; + padding:12px 0 22px 0; +} + +.brand{ + display:flex; + align-items:center; + gap:14px; + text-decoration:none; +} + +.brand img{ + height:60px; + width:auto; + display:block; + object-fit:contain; + background: rgba(255,255,255,0.92); + padding: 6px 12px; + border-radius: 999px; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); +} + +.title{ + display:flex; + flex-direction:column; + line-height:1.1; +} + +.title strong{letter-spacing:.2px} +.title span{ + color:var(--muted); + font-size:13px; + margin-top:2px; +} + +.navlinks{ + display:flex; + gap:12px; + flex-wrap:wrap; + justify-content:flex-end; +} + +.navlinks a{ + text-decoration:none; + padding:8px 10px; + border-radius:12px; + color:var(--muted); + border:1px solid transparent; +} + +.navlinks a:hover{ + color:var(--text); + border-color:rgba(255,255,255,.08); + background:rgba(255,255,255,.03); +} + +.navlinks a.active{ + color:var(--text); + border-color:rgba(255,255,255,.10); + background:rgba(255,255,255,.04); +} + +/* ===== Generic portal shell ===== */ +.portal-shell{ + max-width:1100px; + margin:16px auto 28px auto; + padding:0 18px 20px 18px; +} + +.portal-card, +.detail-card, +.summary-card, +.pay-card{ + background: var(--card-soft); + border: 1px solid rgba(255,255,255,.07); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.portal-card{ + max-width:760px; + margin:24px auto 12px auto; + padding:22px; +} + +.portal-page-header{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:16px; + flex-wrap:wrap; + margin:6px 0 18px 0; +} + +.portal-page-title{ + margin:0; + font-size:24px; + line-height:1.1; +} + +.portal-page-subtitle{ + margin:8px 0 0 0; + color:var(--muted); +} + +.portal-client-name{ + margin:8px 0 0 0; + color:var(--text); + font-size:15px; +} + +.portal-toolbar{ + display:flex; + gap:10px; + flex-wrap:wrap; + align-items:center; +} + +.portal-btn, +.btn, +.pay-btn, +.quote-pick-btn{ + display:inline-flex; + align-items:center; + justify-content:center; + gap:8px; + min-height:42px; + padding:10px 14px; + border-radius:12px; + text-decoration:none; + border:1px solid rgba(255,255,255,.10); + background: rgba(255,255,255,.05); + color:var(--text); + font-weight:600; + cursor:pointer; +} + +.portal-btn:hover, +.btn:hover, +.pay-btn:hover, +.quote-pick-btn:hover{ + border-color:rgba(122,162,255,.45); + box-shadow:0 0 0 4px rgba(122,162,255,.12); + text-decoration:none; +} + +.portal-btn.primary, +.btn.primary{ + background: linear-gradient(135deg, rgba(122,162,255,.95), rgba(98,230,183,.85)); + border-color: transparent; + color:#071017; + font-weight:700; +} + +.portal-btn.primary:hover, +.btn.primary:hover{ + box-shadow:0 0 0 4px rgba(98,230,183,.18); +} + +/* ===== Login / forms ===== */ +.portal-sub{ + color:var(--muted); + margin:0 0 16px 0; +} + +.portal-form{ + display:grid; + gap:14px; +} + +.portal-form label{ + display:block; + font-weight:600; + margin-bottom:6px; +} + +.portal-form input, +.portal-form select, +.pay-selector{ + width:100%; + padding:12px 14px; + border-radius:12px; + border:1px solid rgba(255,255,255,.14); + background: rgba(255,255,255,.06); + color:var(--text); + box-sizing:border-box; + outline:none; +} + +.portal-form input:focus, +.portal-form select:focus, +.pay-selector:focus{ + border-color:rgba(122,162,255,.65); + box-shadow:0 0 0 4px rgba(122,162,255,.12); +} + +.portal-actions{ + display:flex; + gap:10px; + flex-wrap:wrap; + margin-top:4px; +} + +.portal-note{ + margin-top:16px; + color:var(--muted); + font-size:14px; + line-height:1.5; +} + +.portal-msg, +.error-box, +.success-box{ + margin-bottom:16px; + padding:12px 14px; + border-radius:12px; + border:1px solid rgba(255,255,255,.16); + background: rgba(255,255,255,.04); +} + +.error-box{ + border-color: rgba(239, 68, 68, 0.55); + background: rgba(127, 29, 29, 0.22); + color: #fecaca; +} + +.success-box{ + border-color: rgba(34, 197, 94, 0.55); + background: rgba(22, 101, 52, 0.18); + color: #dcfce7; +} + +/* ===== Dashboard ===== */ +.portal-wrap{ + max-width:1100px; + margin:0 auto; + padding:0; +} + +.summary-grid, +.detail-grid{ + display:grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap:14px; + margin: 0 0 18px 0; +} + +.summary-card, +.detail-card{ + padding:18px; +} + +.summary-card h3, +.detail-card h3{ + margin:0 0 8px 0; + font-size:14px; + color:var(--muted); +} + +.summary-card .summary-value, +.detail-card .detail-value{ + font-size:28px; + font-weight:800; + line-height:1.1; + color:var(--text); +} + +.summary-card .summary-sub{ + margin-top:6px; + font-size:12px; + color:var(--muted); +} + +.section-title{ + margin:18px 0 10px 0; + font-size:22px; +} + +.table-card{ + background: var(--card-soft); + border: 1px solid rgba(255,255,255,.07); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow:hidden; +} + +/* ===== Tables ===== */ +table, +.portal-table, +.quote-table{ + width:100%; + border-collapse: collapse; +} + +table.portal-table th, +table.portal-table td, +.quote-table th, +.quote-table td{ + padding: 0.9rem 0.85rem; + border-bottom: 1px solid rgba(255,255,255,0.10); + text-align: left; + vertical-align: middle; +} + +table.portal-table th, +.quote-table th{ + background: rgba(255,255,255,.06); + color: var(--text); + font-size:13px; + letter-spacing:.02em; +} + +table.portal-table tr:hover td, +.quote-table tr:hover td{ + background: rgba(255,255,255,.02); +} + +.invoice-link{ + color: var(--text); + text-decoration: none; + font-weight: 700; +} + +.invoice-link:hover{ + color: var(--accent); + text-decoration: underline; +} + +/* ===== Badges ===== */ +.status-badge, +.quote-badge{ + display: inline-block; + padding: 0.22rem 0.62rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; +} + +.status-paid{ background: rgba(34, 197, 94, 0.18); color: var(--success); } +.status-pending{ background: rgba(245, 158, 11, 0.20); color: var(--warn); } +.status-overdue{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } +.status-other{ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } + +.quote-live{ background: rgba(34, 197, 94, 0.18); color: var(--success); } +.quote-stale{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } + +/* ===== Payments / invoice detail ===== */ +.pay-card{ + padding:18px; + margin-top: 1.25rem; +} + +.pay-selector-row{ + display:flex; + gap:0.75rem; + align-items:center; + flex-wrap:wrap; + margin-top:0.75rem; +} + +.pay-panel{ + margin-top: 1rem; + padding: 1rem; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + background: rgba(255,255,255,0.02); } + +.pay-panel.hidden{ display:none; } + +.pay-btn-square { background:#16a34a; border-color:transparent; color:#fff; } +.pay-btn-wallet { background:#2563eb; border-color:transparent; color:#fff; } +.pay-btn-mobile { background:#7c3aed; border-color:transparent; color:#fff; } +.pay-btn-copy { background:#374151; border-color:transparent; color:#fff; } + +.snapshot-wrap{ + position: relative; + margin-top: 1rem; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 14px; + padding: 1rem; + background: rgba(255,255,255,0.02); +} + +.snapshot-header{ + display:flex; + justify-content:space-between; + gap:1rem; + align-items:flex-start; +} + +.snapshot-meta{ + flex: 1 1 auto; + min-width: 0; + line-height: 1.65; +} + +.snapshot-timer-box{ + width: 220px; + min-height: 132px; + border: 1px solid rgba(255,255,255,0.16); + border-radius: 14px; + background: rgba(0,0,0,0.18); + display:flex; + flex-direction:column; + justify-content:center; + align-items:center; + text-align:center; + padding: 0.9rem; +} + +.snapshot-timer-value{ + font-size: 2rem; + font-weight: 800; + line-height: 1.1; +} + +.snapshot-timer-label{ + margin-top: 0.55rem; + font-size: 0.95rem; + opacity: 0.95; +} + +.snapshot-timer-expired{ color: var(--danger); } + +.lock-box{ + margin-top: 1rem; + border: 1px solid rgba(34, 197, 94, 0.28); + background: rgba(22, 101, 52, 0.16); + border-radius: 12px; + padding: 1rem; +} + +.lock-box.expired{ + border-color: rgba(239, 68, 68, 0.55); + background: rgba(127, 29, 29, 0.22); +} + +.lock-grid{ + display:grid; + grid-template-columns: 1fr 220px; + gap:1rem; + align-items:start; +} + +.lock-code{ + display:block; + margin-top:0.35rem; + padding:0.65rem 0.8rem; + background: rgba(0,0,0,0.22); + border-radius: 8px; + overflow-wrap:anywhere; +} + +.wallet-actions{ + display:flex; + gap:0.75rem; + flex-wrap:wrap; + margin-top:0.9rem; + align-items:center; +} + +.wallet-help{ + margin-top: 0.85rem; + padding: 0.9rem 1rem; + border-radius: 10px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.10); +} + +.wallet-help h4{ + margin: 0 0 0.55rem 0; + font-size: 1rem; +} + +.wallet-help p{ margin: 0.35rem 0; } +.wallet-note{ opacity:0.9; margin-top:0.65rem; } +.mono{ font-family: monospace; } + +.copy-row{ + display:flex; + gap:0.5rem; + flex-wrap:wrap; + align-items:center; + margin-top:0.65rem; +} + +.copy-target{ + flex: 1 1 420px; + min-width: 220px; +} + +.copy-status{ + display:inline-block; + margin-left: 0.5rem; + opacity: 0.9; +} + +/* ===== Footer ===== */ +footer{ + margin:28px auto 20px auto; + max-width:1100px; + padding:0 18px; + color:var(--muted); + font-size:13px; +} + +/* ===== Responsive ===== */ +@media (max-width: 900px){ + .summary-grid, + .detail-grid{ + grid-template-columns:1fr; + } + + .nav{ + align-items:flex-start; + flex-direction:column; + } + + .navlinks{ + justify-content:flex-start; + } + + .brand img{ + height:54px; + } +} + +@media (max-width: 820px){ + .snapshot-header, + .lock-grid{ + grid-template-columns: 1fr; + display:block; + } + + .snapshot-timer-box{ + width: 100%; + margin-top: 1rem; + min-height: 110px; + } +} + + +/* ===== Fixed CAD / Oracle status bar ===== */ +body{ + padding-bottom: 56px; +} + +.otb-statusbar{ + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 8px 14px; + background: rgba(8, 16, 32, 0.94); + border-top: 1px solid rgba(255,255,255,.10); + backdrop-filter: blur(8px); + box-shadow: 0 -8px 24px rgba(0,0,0,.28); +} + +.otb-statusbar-inner{ + width: 100%; + max-width: 1100px; + display: flex; + gap: 10px; + align-items: center; + justify-content: center; + flex-wrap: wrap; + text-align: center; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.otb-statusbar strong{ + color: var(--text); + font-weight: 700; +} + +.otb-statusbar a{ + color: var(--accent2); + text-decoration: none; + font-weight: 600; +} + +.otb-statusbar a:hover{ + text-decoration: underline; +} + +.otb-dot{ + width: 6px; + height: 6px; + border-radius: 999px; + display: inline-block; + background: rgba(255,255,255,.25); + flex: 0 0 auto; +} + +/* ===== Payment method chips ===== */ +.payment-method{ + display: inline-flex; + align-items: center; + gap: 7px; + margin-top: 7px; + padding: 4px 9px; + border-radius: 999px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.08); + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: .01em; +} + +.payment-method::before{ + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; + background: #7aa2ff; + box-shadow: 0 0 0 3px rgba(255,255,255,.04); +} + +.payment-square::before{ background: #7dd3fc; } +.payment-etransfer::before{ background: #86efac; } +.payment-etho::before{ background: #b084ff; } +.payment-etica::before{ background: #4fd1c5; } +.payment-alt::before{ background: #fbbf24; } +.payment-cad::before{ background: #cbd5e1; } + +/* ===== Slightly stronger row hover ===== */ +table.portal-table tbody tr:hover td, +.quote-table tbody tr:hover td{ + background: rgba(255,255,255,.035); + transition: background .14s ease; +} + +@media (max-width: 700px){ + body{ + padding-bottom: 72px; + } + + .otb-statusbar-inner{ + font-size: 11px; + line-height: 1.25; + } +} + + +@media (max-width: 900px){ + .dropdown{ + width:100%; + } + + .dropdown-toggle{ + width:100%; + } + + .dropdown-menu{ + position:static; + right:auto; + top:auto; + min-width:100%; + margin-top:6px; + } +} + + +/* ===== Shared services dropdown ===== */ +.navlinks{ + display:flex; + gap:12px; + flex-wrap:wrap; + justify-content:flex-end; + align-items:center; +} + +.dropdown{ + position:relative; + display:inline-block; +} + +.dropdown-toggle{ + display:inline-block; + cursor:pointer; +} + +.dropdown-menu{ + position:absolute; + top:calc(100% + 8px); + right:0; + min-width:220px; + display:none; + padding:10px; + border-radius:14px; + background:rgba(18,24,37,.98); + border:1px solid rgba(255,255,255,.08); + box-shadow:0 16px 40px rgba(0,0,0,.35); + z-index:9999; +} + +.dropdown:hover .dropdown-menu, +.dropdown:focus-within .dropdown-menu{ + display:block; +} + +.dropdown-menu a{ + display:block; + padding:9px 10px; + border-radius:10px; + color:var(--muted); + text-decoration:none; + white-space:nowrap; + margin:0; +} + +.dropdown-menu a + a{ + margin-top:4px; +} + +.dropdown-menu a:hover{ + color:var(--text); + background:rgba(255,255,255,.04); +} + +@media (max-width: 900px){ + .dropdown{ + width:100%; + } + + .dropdown-toggle{ + width:100%; + } + + .dropdown-menu{ + position:static; + right:auto; + top:auto; + min-width:100%; + margin-top:6px; + } +} + + +/* ===== OTB shared branding ===== */ +html[data-theme="dark"]{ + --otb-bg:#081225; + --otb-bg2:#09172d; + --otb-text:#e8eefc; + --otb-muted:#aab6d6; + --otb-panel:rgba(18,24,37,.98); + --otb-panel-soft:rgba(18,24,37,.78); + --otb-line:rgba(255,255,255,.08); +} + +html[data-theme="light"]{ + --otb-bg:#f4f7fb; + --otb-bg2:#eef3f9; + --otb-text:#0f172a; + --otb-muted:#475569; + --otb-panel:rgba(255,255,255,.98); + --otb-panel-soft:rgba(255,255,255,.88); + --otb-line:rgba(15,23,42,.10); +} + +body{ + padding-bottom:56px; +} + +.site-container{ + max-width:1100px; + margin:0 auto; + padding:20px 18px 0 18px; +} + +.site-header{ + width:100%; +} + +.site-nav{ + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; + padding:12px 0 22px 0; +} + +.site-brand{ + display:flex; + align-items:center; + gap:14px; + text-decoration:none; + color:inherit; +} + +.site-brand img{ + height:60px; + width:auto; + display:block; + object-fit:contain; + background:rgba(255,255,255,0.92); + padding:6px 12px; + border-radius:999px; + box-shadow:0 8px 24px rgba(0,0,0,0.35); +} + +.site-title{ + display:flex; + flex-direction:column; + line-height:1.1; +} + +.site-title strong{ + letter-spacing:.2px; + color:var(--otb-text); +} + +.site-title span{ + color:var(--otb-muted); + font-size:13px; + margin-top:2px; +} + +.site-nav-right{ + display:flex; + align-items:center; + gap:14px; + flex-wrap:wrap; + justify-content:flex-end; +} + +.site-navlinks{ + display:flex; + gap:12px; + flex-wrap:wrap; + justify-content:flex-end; + align-items:center; +} + +.site-navlinks > a, +.dropdown-toggle{ + text-decoration:none; + padding:8px 10px; + border-radius:12px; + color:var(--otb-muted); + border:1px solid transparent; +} + +.site-navlinks > a:hover, +.dropdown-toggle:hover{ + color:var(--otb-text); + border-color:var(--otb-line); + background:rgba(255,255,255,.03); +} + +.dropdown{ + position:relative; + display:inline-block; +} + +.dropdown-toggle{ + display:inline-block; + cursor:pointer; +} + +.dropdown-menu{ + position:absolute; + top:calc(100% + 8px); + right:0; + min-width:220px; + display:none; + padding:10px; + border-radius:14px; + background:var(--otb-panel); + border:1px solid var(--otb-line); + box-shadow:0 16px 40px rgba(0,0,0,.35); + z-index:9999; +} + +.dropdown:hover .dropdown-menu, +.dropdown:focus-within .dropdown-menu{ + display:block; +} + +.dropdown-menu a{ + display:block; + padding:9px 10px; + border-radius:10px; + color:var(--otb-muted); + text-decoration:none; + white-space:nowrap; + margin:0; +} + +.dropdown-menu a + a{ + margin-top:4px; +} + +.dropdown-menu a:hover{ + color:var(--otb-text); + background:rgba(255,255,255,.04); +} + +.otb-theme-switch{ + position:relative; + display:inline-block; + width:54px; + height:30px; + flex:0 0 auto; +} + +.otb-theme-switch input{ + opacity:0; + width:0; + height:0; +} + +.otb-theme-slider{ + position:absolute; + inset:0; + cursor:pointer; + background:rgba(255,255,255,.10); + border:1px solid var(--otb-line); + transition:.2s; + border-radius:999px; +} + +.otb-theme-slider:before{ + content:""; + position:absolute; + height:22px; + width:22px; + left:3px; + top:3px; + background:var(--otb-text); + transition:.2s; + border-radius:50%; +} + +.otb-theme-switch input:checked + .otb-theme-slider:before{ + transform:translateX(24px); +} + +.otb-statusbar{ + position:fixed; + left:0; + right:0; + bottom:0; + z-index:9999; + display:flex; + align-items:center; + justify-content:center; + min-height:42px; + padding:8px 14px; + background:var(--otb-panel); + border-top:1px solid var(--otb-line); + backdrop-filter:blur(8px); + box-shadow:0 -8px 24px rgba(0,0,0,.28); +} + +.otb-statusbar-inner{ + width:100%; + max-width:1100px; + display:flex; + gap:10px; + align-items:center; + justify-content:center; + flex-wrap:wrap; + text-align:center; + color:var(--otb-muted); + font-size:12px; + line-height:1.35; +} + +.otb-statusbar strong{ + color:var(--otb-text); + font-weight:700; +} + +.otb-statusbar a{ + color:#62e6b7; + text-decoration:none; + font-weight:600; +} + +.otb-statusbar a:hover{ + text-decoration:underline; +} + +.otb-dot{ + width:6px; + height:6px; + border-radius:999px; + display:inline-block; + background:rgba(255,255,255,.25); + flex:0 0 auto; +} + +@media (max-width: 900px){ + .site-nav{ + align-items:flex-start; + flex-direction:column; + } + + .site-nav-right{ + width:100%; + justify-content:space-between; + } + + .site-navlinks{ + justify-content:flex-start; + } + + .dropdown{ + width:100%; + } + + .dropdown-toggle{ + width:100%; + } + + .dropdown-menu{ + position:static; + right:auto; + top:auto; + min-width:100%; + margin-top:6px; + } + + .site-brand img{ + height:54px; + } +} + +@media (max-width: 700px){ + body{ + padding-bottom:72px; + } + + .otb-statusbar-inner{ + font-size:11px; + line-height:1.25; + } +} \ No newline at end of file diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..50be91b --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,44 @@ + + + + + + OTB Billing Admin Login + + + + + {% include "includes/site_nav.html" %} +
+
+
+

OTB Billing Admin Login

+

Authorized administrative access only.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + Client Portal +
+
+
+
+
+ {% include "footer.html" %} + + + diff --git a/templates/dashboard.html b/templates/dashboard.html index 5b6ee23..acb2732 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -16,6 +16,12 @@

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

+

+ Admin | + Admin Logout | + Client Portal +

+ {% if request.args.get('pkg_email') == '1' %}
Accounting package emailed successfully. diff --git a/templates/footer.html b/templates/footer.html index 1d67be7..ba209b4 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -222,27 +222,6 @@ table tr:hover td { } /* Toggle switch */ -.otb-theme-toggle-wrap { - position: fixed; - top: 12px; - right: 12px; - z-index: 9999; - display: flex; - align-items: center; - gap: 8px; - background: var(--otb-card); - color: var(--otb-text); - border: 1px solid var(--otb-border); - box-shadow: var(--otb-shadow); - border-radius: 999px; - padding: 8px 12px; -} - -.otb-theme-toggle-wrap .label { - font-size: 12px; - color: var(--otb-muted); -} - .otb-switch { position: relative; display: inline-block; @@ -284,15 +263,6 @@ table tr:hover td { transform: translateX(22px); } - -
- Theme - -
-