From 446462e0a0ad44e28f4373697edcf5ff62dbfb41 Mon Sep 17 00:00:00 2001 From: def Date: Fri, 27 Mar 2026 21:30:47 +0000 Subject: [PATCH] cleanup: add .gitignore and remove backup/script noise from repo --- .gitignore | 28 +- .../app.py.attach-debug.20260326-020213.bak | 6603 ---------------- .../app.py.attachment-fix.20260326-021226.bak | 6603 ---------------- ...auto-expire-pending-v2.20260327-040852.bak | 6738 ---------------- ...py.auto-expire-pending.20260327-035958.bak | 6698 ---------------- ...py.auto-expire-pending.20260327-040203.bak | 6698 ---------------- ....correct-payment-block.20260326-043806.bak | 6642 ---------------- ...pp.py.email-attachment.20260325-022911.bak | 6582 ---------------- ...pp.py.email-attachment.20260325-023107.bak | 6596 ---------------- ...mail-body-explorer-fix.20260326-033501.bak | 6663 ---------------- ...y.email-body-fix-clean.20260326-040645.bak | 6698 ---------------- ...py.email-explorer-safe.20260326-040915.bak | 6610 ---------------- backend/app.py.envload.20260326-025333.bak | 6604 ---------------- .../app.py.explorer-links.20260326-032929.bak | 6663 ---------------- .../app.py.explorer-links.20260326-032943.bak | 6663 ---------------- ...py.final-email-cleanup.20260327-020603.bak | 6688 ---------------- ...pp.py.fix-dead-pending.20260327-042733.bak | 6739 ---------------- ...fix-missing-pdf-import.20260326-050344.bak | 6682 ---------------- ...y.helper-pdf-route-fix.20260327-015238.bak | 6682 ---------------- .../app.py.helper-rebuild.20260326-050853.bak | 6682 ---------------- ....invoice-pdf-order-fix.20260326-041449.bak | 6641 ---------------- ...voice-pdf-payments-fix.20260326-030703.bak | 6610 ---------------- ...yment-block-global-fix.20260326-043120.bak | 6642 ---------------- ...payment-block-live-fix.20260326-044117.bak | 6642 ---------------- ...y.payment-block-polish.20260326-032040.bak | 6611 ---------------- ...ment-email-and-pdf-fix.20260326-041126.bak | 6641 ---------------- ....payment-email-logging.20260326-045639.bak | 6681 ---------------- ....py.payment-layout-fix.20260327-013334.bak | 6682 ---------------- ...payment-lines-and-rate.20260326-042154.bak | 6642 ---------------- ...app.py.pdf-context-fix.20260326-030343.bak | 6610 ---------------- backend/app.py.pdf-fix.20260326-030059.bak | 6607 ---------------- ....pending-payment-logic.20260327-184101.bak | 6687 ---------------- ...ircular-import-removal.20260326-050604.bak | 6684 ---------------- .../app.py.pre-indent-fix.20260327-042939.bak | 6752 ----------------- .../app.py.retry-email.20260327-023137.bak | 6687 ---------------- .../app.py.retry-email.20260327-023404.bak | 6687 ---------------- ...al-payment-email-error.20260326-024852.bak | 6603 ---------------- ...pp.py.safe-pending-fix.20260327-043630.bak | 6687 ---------------- backend/auto_expire_patch.sh | 74 - backend/auto_expire_patch_v2.sh | 44 - backend/fix_pending_payment_logic.sh | 116 - backend/fix_portal_indent.sh | 29 - backend/recover_otb_billing.sh | 26 - .../app.py.bak | 1184 --- .../payments_list.html.bak | 100 - .../backup_logo_support_2026-03-09/app.py.bak | 1583 ---- .../dashboard.html.bak | 36 - .../invoice_view.html.bak | 202 - .../settings.html.bak | 169 - .../app.py.bak | 2342 ------ .../dashboard.html.bak | 60 - .../invoices_view.html.bak | 235 - .../revenue.html.bak | 91 - .../settings.html.bak | 202 - shell-scripts/backpatch.sh | 49 - shell-scripts/email-retry-v2.sh | 60 - shell-scripts/email-retry.sh | 51 - shell-scripts/production-safe.sh | 63 - shell-scripts/update1.sh | 51 - ...iew.html.square_button_20260313-055733.bak | 256 - ...ail.html.square_button_20260313-055733.bak | 187 - 61 files changed, 16 insertions(+), 253552 deletions(-) delete mode 100644 backend/app.py.attach-debug.20260326-020213.bak delete mode 100644 backend/app.py.attachment-fix.20260326-021226.bak delete mode 100644 backend/app.py.auto-expire-pending-v2.20260327-040852.bak delete mode 100644 backend/app.py.auto-expire-pending.20260327-035958.bak delete mode 100644 backend/app.py.auto-expire-pending.20260327-040203.bak delete mode 100644 backend/app.py.correct-payment-block.20260326-043806.bak delete mode 100644 backend/app.py.email-attachment.20260325-022911.bak delete mode 100644 backend/app.py.email-attachment.20260325-023107.bak delete mode 100644 backend/app.py.email-body-explorer-fix.20260326-033501.bak delete mode 100644 backend/app.py.email-body-fix-clean.20260326-040645.bak delete mode 100644 backend/app.py.email-explorer-safe.20260326-040915.bak delete mode 100644 backend/app.py.envload.20260326-025333.bak delete mode 100644 backend/app.py.explorer-links.20260326-032929.bak delete mode 100644 backend/app.py.explorer-links.20260326-032943.bak delete mode 100644 backend/app.py.final-email-cleanup.20260327-020603.bak delete mode 100644 backend/app.py.fix-dead-pending.20260327-042733.bak delete mode 100644 backend/app.py.fix-missing-pdf-import.20260326-050344.bak delete mode 100644 backend/app.py.helper-pdf-route-fix.20260327-015238.bak delete mode 100644 backend/app.py.helper-rebuild.20260326-050853.bak delete mode 100644 backend/app.py.invoice-pdf-order-fix.20260326-041449.bak delete mode 100644 backend/app.py.invoice-pdf-payments-fix.20260326-030703.bak delete mode 100644 backend/app.py.payment-block-global-fix.20260326-043120.bak delete mode 100644 backend/app.py.payment-block-live-fix.20260326-044117.bak delete mode 100644 backend/app.py.payment-block-polish.20260326-032040.bak delete mode 100644 backend/app.py.payment-email-and-pdf-fix.20260326-041126.bak delete mode 100644 backend/app.py.payment-email-logging.20260326-045639.bak delete mode 100644 backend/app.py.payment-layout-fix.20260327-013334.bak delete mode 100644 backend/app.py.payment-lines-and-rate.20260326-042154.bak delete mode 100644 backend/app.py.pdf-context-fix.20260326-030343.bak delete mode 100644 backend/app.py.pdf-fix.20260326-030059.bak delete mode 100644 backend/app.py.pending-payment-logic.20260327-184101.bak delete mode 100644 backend/app.py.pre-circular-import-removal.20260326-050604.bak delete mode 100644 backend/app.py.pre-indent-fix.20260327-042939.bak delete mode 100644 backend/app.py.retry-email.20260327-023137.bak delete mode 100644 backend/app.py.retry-email.20260327-023404.bak delete mode 100644 backend/app.py.reveal-payment-email-error.20260326-024852.bak delete mode 100644 backend/app.py.safe-pending-fix.20260327-043630.bak delete mode 100755 backend/auto_expire_patch.sh delete mode 100755 backend/auto_expire_patch_v2.sh delete mode 100755 backend/fix_pending_payment_logic.sh delete mode 100755 backend/fix_portal_indent.sh delete mode 100755 backend/recover_otb_billing.sh delete mode 100644 build-backups/backup_fix_void_route_2026-03-08/app.py.bak delete mode 100644 build-backups/backup_fix_void_route_2026-03-08/payments_list.html.bak delete mode 100644 build-backups/backup_logo_support_2026-03-09/app.py.bak delete mode 100644 build-backups/backup_logo_support_2026-03-09/dashboard.html.bak delete mode 100644 build-backups/backup_logo_support_2026-03-09/invoice_view.html.bak delete mode 100644 build-backups/backup_logo_support_2026-03-09/settings.html.bak delete mode 100644 build-backups/backup_restore_email_layer_2026-03-09/app.py.bak delete mode 100644 build-backups/backup_restore_email_layer_2026-03-09/dashboard.html.bak delete mode 100644 build-backups/backup_restore_email_layer_2026-03-09/invoices_view.html.bak delete mode 100644 build-backups/backup_restore_email_layer_2026-03-09/revenue.html.bak delete mode 100644 build-backups/backup_restore_email_layer_2026-03-09/settings.html.bak delete mode 100755 shell-scripts/backpatch.sh delete mode 100755 shell-scripts/email-retry-v2.sh delete mode 100755 shell-scripts/email-retry.sh delete mode 100755 shell-scripts/production-safe.sh delete mode 100755 shell-scripts/update1.sh delete mode 100644 templates/invoices/view.html.square_button_20260313-055733.bak delete mode 100644 templates/portal_invoice_detail.html.square_button_20260313-055733.bak diff --git a/.gitignore b/.gitignore index 2cf4fe8..569142f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,19 @@ +# backups +*.bak +*.bak.* + +# archives +*.zip + +# local scripts (not part of app) +shell-scripts/ + +# temp patch / helper scripts +backend/*.sh + +# python cache __pycache__/ *.pyc + +# env files (just in case later) .env -venv/ -.venv/ -instance/ -*.sqlite -*.log - -# local backup/runtime clutter -backups/ -run/ -backup_pre_*/ -*.bak.* -backend/app.py.fix_indent_backup diff --git a/backend/app.py.attach-debug.20260326-020213.bak b/backend/app.py.attach-debug.20260326-020213.bak deleted file mode 100644 index 8426d0b..0000000 --- a/backend/app.py.attach-debug.20260326-020213.bak +++ /dev/null @@ -1,6603 +0,0 @@ -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 deleted file mode 100644 index 8426d0b..0000000 --- a/backend/app.py.attachment-fix.20260326-021226.bak +++ /dev/null @@ -1,6603 +0,0 @@ -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.auto-expire-pending-v2.20260327-040852.bak b/backend/app.py.auto-expire-pending-v2.20260327-040852.bak deleted file mode 100644 index 45d4159..0000000 --- a/backend/app.py.auto-expire-pending-v2.20260327-040852.bak +++ /dev/null @@ -1,6738 +0,0 @@ -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, - }] - - import time - - for attempt in range(3): - try: - 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"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") - if attempt < 2: - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False - 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 - - 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): - # === AUTO-EXPIRE STALE PENDING CRYPTO PAYMENTS === - try: - from datetime import datetime, timedelta - - conn2 = get_db_connection() - cur2 = conn2.cursor(dictionary=True) - - cur2.execute( - "SELECT id, payment_status, txid, created_at " - "FROM payments " - "WHERE invoice_id = %s AND payment_method = 'crypto' " - "ORDER BY id DESC LIMIT 1", - (invoice_id,) - ) - last_payment = cur2.fetchone() - - if last_payment: - is_pending = str(last_payment.get("payment_status") or "").lower() == "pending" - has_tx = bool(last_payment.get("txid")) - - created_at = last_payment.get("created_at") - is_expired = False - - if created_at: - is_expired = datetime.utcnow() > (created_at + timedelta(minutes=15)) - - if is_pending and not has_tx and is_expired: - cur2.execute( - "UPDATE payments SET payment_status = 'expired' WHERE id = %s", - (last_payment["id"],) - ) - conn2.commit() - print(f"[auto-expire] expired stale payment id={last_payment['id']} invoice_id={invoice_id}") - - conn2.close() - - except Exception as e: - print(f"[auto-expire] error: {e}") - # === END AUTO-EXPIRE === - - 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.auto-expire-pending.20260327-035958.bak b/backend/app.py.auto-expire-pending.20260327-035958.bak deleted file mode 100644 index 59fec9c..0000000 --- a/backend/app.py.auto-expire-pending.20260327-035958.bak +++ /dev/null @@ -1,6698 +0,0 @@ -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, - }] - - import time - - for attempt in range(3): - try: - 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"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") - if attempt < 2: - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False - 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 - - 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.auto-expire-pending.20260327-040203.bak b/backend/app.py.auto-expire-pending.20260327-040203.bak deleted file mode 100644 index 59fec9c..0000000 --- a/backend/app.py.auto-expire-pending.20260327-040203.bak +++ /dev/null @@ -1,6698 +0,0 @@ -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, - }] - - import time - - for attempt in range(3): - try: - 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"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") - if attempt < 2: - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False - 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 - - 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.correct-payment-block.20260326-043806.bak b/backend/app.py.correct-payment-block.20260326-043806.bak deleted file mode 100644 index 70b8872..0000000 --- a/backend/app.py.correct-payment-block.20260326-043806.bak +++ /dev/null @@ -1,6642 +0,0 @@ -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 deleted file mode 100644 index cbf0879..0000000 --- a/backend/app.py.email-attachment.20260325-022911.bak +++ /dev/null @@ -1,6582 +0,0 @@ -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 deleted file mode 100644 index e9966fa..0000000 --- a/backend/app.py.email-attachment.20260325-023107.bak +++ /dev/null @@ -1,6596 +0,0 @@ -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 deleted file mode 100644 index 52fcdb6..0000000 --- a/backend/app.py.email-body-explorer-fix.20260326-033501.bak +++ /dev/null @@ -1,6663 +0,0 @@ -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 deleted file mode 100644 index 1630836..0000000 --- a/backend/app.py.email-body-fix-clean.20260326-040645.bak +++ /dev/null @@ -1,6698 +0,0 @@ -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 deleted file mode 100644 index 76c10fe..0000000 --- a/backend/app.py.email-explorer-safe.20260326-040915.bak +++ /dev/null @@ -1,6610 +0,0 @@ -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 deleted file mode 100644 index f5cc3b7..0000000 --- a/backend/app.py.envload.20260326-025333.bak +++ /dev/null @@ -1,6604 +0,0 @@ -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 deleted file mode 100644 index 52fcdb6..0000000 --- a/backend/app.py.explorer-links.20260326-032929.bak +++ /dev/null @@ -1,6663 +0,0 @@ -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 deleted file mode 100644 index 52fcdb6..0000000 --- a/backend/app.py.explorer-links.20260326-032943.bak +++ /dev/null @@ -1,6663 +0,0 @@ -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 deleted file mode 100644 index 7872ea4..0000000 --- a/backend/app.py.final-email-cleanup.20260327-020603.bak +++ /dev/null @@ -1,6688 +0,0 @@ -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-dead-pending.20260327-042733.bak b/backend/app.py.fix-dead-pending.20260327-042733.bak deleted file mode 100644 index 3ce26d7..0000000 --- a/backend/app.py.fix-dead-pending.20260327-042733.bak +++ /dev/null @@ -1,6739 +0,0 @@ -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, - }] - - import time - - for attempt in range(3): - try: - 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"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") - if attempt < 2: - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False - 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 - - 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): - # === AUTO-EXPIRE STALE PENDING CRYPTO PAYMENTS === - try: - from datetime import datetime, timedelta - - conn2 = get_db_connection() - cur2 = conn2.cursor(dictionary=True) - - cur2.execute( - "SELECT id, payment_method, payment_currency, payment_status, txid, created_at " - "FROM payments " - "WHERE invoice_id = %s " - "AND UPPER(COALESCE(payment_currency,'')) IN ('ETHO','ETI','EGAZ','ETH','ARB') " - "ORDER BY id DESC LIMIT 1", - (invoice_id,) - ) - last_payment = cur2.fetchone() - - if last_payment: - is_pending = str(last_payment.get("payment_status") or "").lower() == "pending" - has_tx = bool(last_payment.get("txid")) - - created_at = last_payment.get("created_at") - is_expired = False - - if created_at: - is_expired = datetime.utcnow() > (created_at + timedelta(minutes=15)) - - if is_pending and not has_tx and is_expired: - cur2.execute( - "UPDATE payments SET payment_status = 'expired' WHERE id = %s", - (last_payment["id"],) - ) - conn2.commit() - print(f"[auto-expire] expired stale payment id={last_payment['id']} invoice_id={invoice_id}") - - conn2.close() - - except Exception as e: - print(f"[auto-expire] error: {e}") - # === END AUTO-EXPIRE === - - 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 deleted file mode 100644 index aa6048a..0000000 --- a/backend/app.py.fix-missing-pdf-import.20260326-050344.bak +++ /dev/null @@ -1,6682 +0,0 @@ -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 deleted file mode 100644 index aa6048a..0000000 --- a/backend/app.py.helper-pdf-route-fix.20260327-015238.bak +++ /dev/null @@ -1,6682 +0,0 @@ -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 deleted file mode 100644 index aa6048a..0000000 --- a/backend/app.py.helper-rebuild.20260326-050853.bak +++ /dev/null @@ -1,6682 +0,0 @@ -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 deleted file mode 100644 index 874a8ad..0000000 --- a/backend/app.py.invoice-pdf-order-fix.20260326-041449.bak +++ /dev/null @@ -1,6641 +0,0 @@ -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 deleted file mode 100644 index 76c10fe..0000000 --- a/backend/app.py.invoice-pdf-payments-fix.20260326-030703.bak +++ /dev/null @@ -1,6610 +0,0 @@ -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 deleted file mode 100644 index 70b8872..0000000 --- a/backend/app.py.payment-block-global-fix.20260326-043120.bak +++ /dev/null @@ -1,6642 +0,0 @@ -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 deleted file mode 100644 index 70b8872..0000000 --- a/backend/app.py.payment-block-live-fix.20260326-044117.bak +++ /dev/null @@ -1,6642 +0,0 @@ -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 deleted file mode 100644 index 96d899d..0000000 --- a/backend/app.py.payment-block-polish.20260326-032040.bak +++ /dev/null @@ -1,6611 +0,0 @@ -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 deleted file mode 100644 index 874a8ad..0000000 --- a/backend/app.py.payment-email-and-pdf-fix.20260326-041126.bak +++ /dev/null @@ -1,6641 +0,0 @@ -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 deleted file mode 100644 index 3532f21..0000000 --- a/backend/app.py.payment-email-logging.20260326-045639.bak +++ /dev/null @@ -1,6681 +0,0 @@ -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 deleted file mode 100644 index aa6048a..0000000 --- a/backend/app.py.payment-layout-fix.20260327-013334.bak +++ /dev/null @@ -1,6682 +0,0 @@ -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 deleted file mode 100644 index 70b8872..0000000 --- a/backend/app.py.payment-lines-and-rate.20260326-042154.bak +++ /dev/null @@ -1,6642 +0,0 @@ -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 deleted file mode 100644 index 54ec377..0000000 --- a/backend/app.py.pdf-context-fix.20260326-030343.bak +++ /dev/null @@ -1,6610 +0,0 @@ -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 deleted file mode 100644 index 3727061..0000000 --- a/backend/app.py.pdf-fix.20260326-030059.bak +++ /dev/null @@ -1,6607 +0,0 @@ -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.pending-payment-logic.20260327-184101.bak b/backend/app.py.pending-payment-logic.20260327-184101.bak deleted file mode 100644 index 5d1aa55..0000000 --- a/backend/app.py.pending-payment-logic.20260327-184101.bak +++ /dev/null @@ -1,6687 +0,0 @@ -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: - 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 - - 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.pre-circular-import-removal.20260326-050604.bak b/backend/app.py.pre-circular-import-removal.20260326-050604.bak deleted file mode 100644 index 39c6616..0000000 --- a/backend/app.py.pre-circular-import-removal.20260326-050604.bak +++ /dev/null @@ -1,6684 +0,0 @@ -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.pre-indent-fix.20260327-042939.bak b/backend/app.py.pre-indent-fix.20260327-042939.bak deleted file mode 100644 index b6cf98e..0000000 --- a/backend/app.py.pre-indent-fix.20260327-042939.bak +++ /dev/null @@ -1,6752 +0,0 @@ -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, - }] - - import time - - for attempt in range(3): - try: - 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"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") - if attempt < 2: - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False - 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 - - 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): - # === AUTO-EXPIRE STALE PENDING CRYPTO PAYMENTS === - try: - from datetime import datetime, timedelta - - conn2 = get_db_connection() - cur2 = conn2.cursor(dictionary=True) - - cur2.execute( - "SELECT id, payment_method, payment_currency, payment_status, txid, created_at " - "FROM payments " - "WHERE invoice_id = %s " - "AND UPPER(COALESCE(payment_currency,'')) IN ('ETHO','ETI','EGAZ','ETH','ARB') " - "ORDER BY id DESC LIMIT 1", - (invoice_id,) - ) - last_payment = cur2.fetchone() - - if last_payment: - is_pending = str(last_payment.get("payment_status") or "").lower() == "pending" - has_tx = bool(last_payment.get("txid")) - - created_at = last_payment.get("created_at") - is_expired = False - - if created_at: - is_expired = datetime.utcnow() > (created_at + timedelta(minutes=15)) - - if is_pending and not has_tx and is_expired: - cur2.execute( - "UPDATE payments SET payment_status = 'expired' WHERE id = %s", - (last_payment["id"],) - ) - conn2.commit() - print(f"[auto-expire] expired stale payment id={last_payment['id']} invoice_id={invoice_id}") - - conn2.close() - - except Exception as e: - print(f"[auto-expire] error: {e}") - # === END AUTO-EXPIRE === - - 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() - - - # === KILL DEAD PENDING PAYMENT (no txid + expired lock) === - if pending_crypto_payment: - try: - txid = pending_crypto_payment.get("txid") - lock_expired = pending_crypto_payment.get("lock_expired") - - if (not txid) and lock_expired: - pending_crypto_payment = None - payment_id = "" - except Exception as e: - print("[dead pending cleanup error]", e) - -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.retry-email.20260327-023137.bak b/backend/app.py.retry-email.20260327-023137.bak deleted file mode 100644 index 5d1aa55..0000000 --- a/backend/app.py.retry-email.20260327-023137.bak +++ /dev/null @@ -1,6687 +0,0 @@ -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: - 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 - - 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.retry-email.20260327-023404.bak b/backend/app.py.retry-email.20260327-023404.bak deleted file mode 100644 index 5d1aa55..0000000 --- a/backend/app.py.retry-email.20260327-023404.bak +++ /dev/null @@ -1,6687 +0,0 @@ -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: - 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 - - 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 deleted file mode 100644 index 8426d0b..0000000 --- a/backend/app.py.reveal-payment-email-error.20260326-024852.bak +++ /dev/null @@ -1,6603 +0,0 @@ -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.safe-pending-fix.20260327-043630.bak b/backend/app.py.safe-pending-fix.20260327-043630.bak deleted file mode 100644 index 5d1aa55..0000000 --- a/backend/app.py.safe-pending-fix.20260327-043630.bak +++ /dev/null @@ -1,6687 +0,0 @@ -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: - 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 - - 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/auto_expire_patch.sh b/backend/auto_expire_patch.sh deleted file mode 100755 index b4850b4..0000000 --- a/backend/auto_expire_patch.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -set -e - -STAMP="$(date +%Y%m%d-%H%M%S)" -cp app.py "app.py.auto-expire-pending.${STAMP}.bak" - -python3 <<'PY' -from pathlib import Path - -p = Path("app.py") -text = p.read_text() - -anchor = "def portal_invoice" - -if anchor not in text: - raise SystemExit("FAILED: portal_invoice route not found") - -# find insertion point (start of function body) -idx = text.index(anchor) -start = text.index(":", idx) + 1 - -inject_code = ''' - # === AUTO-EXPIRE STALE PENDING CRYPTO PAYMENTS === - try: - from datetime import datetime, timedelta - - conn2 = get_db_connection() - cur2 = conn2.cursor(dictionary=True) - - cur2.execute( - "SELECT id, payment_status, txid, created_at " - "FROM payments " - "WHERE invoice_id = %s AND payment_method = 'crypto' " - "ORDER BY id DESC LIMIT 1", - (invoice_id,) - ) - last_payment = cur2.fetchone() - - if last_payment: - is_pending = str(last_payment.get("payment_status") or "").lower() == "pending" - has_tx = bool(last_payment.get("txid")) - - created_at = last_payment.get("created_at") - is_expired = False - - if created_at: - is_expired = datetime.utcnow() > (created_at + timedelta(minutes=15)) - - if is_pending and not has_tx and is_expired: - cur2.execute( - "UPDATE payments SET payment_status = 'expired' WHERE id = %s", - (last_payment["id"],) - ) - conn2.commit() - print(f"[auto-expire] expired stale payment id={last_payment['id']} invoice_id={invoice_id}") - - conn2.close() - - except Exception as e: - print(f"[auto-expire] error: {e}") - # === END AUTO-EXPIRE === -''' - -text = text[:start] + inject_code + text[start:] - -p.write_text(text) -print("OK: auto-expire logic injected cleanly") -PY - -python3 -m py_compile app.py -echo "PY_COMPILE_OK" - -sudo systemctl restart otb_billing -sudo systemctl status otb_billing --no-pager -l diff --git a/backend/auto_expire_patch_v2.sh b/backend/auto_expire_patch_v2.sh deleted file mode 100755 index 7d7430f..0000000 --- a/backend/auto_expire_patch_v2.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -set -e - -STAMP="$(date +%Y%m%d-%H%M%S)" -cp app.py "app.py.auto-expire-pending-v2.${STAMP}.bak" - -python3 <<'PY' -from pathlib import Path - -p = Path("app.py") -text = p.read_text() - -old = """ cur2.execute( - "SELECT id, payment_status, txid, created_at " - "FROM payments " - "WHERE invoice_id = %s AND payment_method = 'crypto' " - "ORDER BY id DESC LIMIT 1", - (invoice_id,) - ) -""" - -new = """ cur2.execute( - "SELECT id, payment_method, payment_currency, payment_status, txid, created_at " - "FROM payments " - "WHERE invoice_id = %s " - "AND UPPER(COALESCE(payment_currency,'')) IN ('ETHO','ETI','EGAZ','ETH','ARB') " - "ORDER BY id DESC LIMIT 1", - (invoice_id,) - ) -""" - -if old not in text: - raise SystemExit("FAILED: old auto-expire query not found") - -text = text.replace(old, new, 1) -p.write_text(text) -print("OK: auto-expire query updated for real crypto rows") -PY - -python3 -m py_compile app.py -echo "PY_COMPILE_OK" - -sudo systemctl restart otb_billing -sudo systemctl status otb_billing --no-pager -l diff --git a/backend/fix_pending_payment_logic.sh b/backend/fix_pending_payment_logic.sh deleted file mode 100755 index f7ce1d8..0000000 --- a/backend/fix_pending_payment_logic.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/bin/bash -set -e - -cd /home/def/otb_billing/backend || exit 1 - -STAMP="$(date +%Y%m%d-%H%M%S)" -cp app.py "app.py.pending-payment-logic.${STAMP}.bak" - -python3 <<'PY' -from pathlib import Path - -p = Path("app.py") -text = p.read_text() - -old_block_1 = """ 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(): -""" - -new_block_1 = """ payment_id = (request.args.get("payment_id") or "").strip() - if not payment_id and pending_crypto_payment: - stale_pending_without_tx = False - try: - 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) - stale_pending_without_tx = ( - str(pending_crypto_payment.get("payment_status") or "").lower() == "pending" - and not pending_crypto_payment.get("txid") - and created_dt is not None - and datetime.now(timezone.utc) >= (created_dt + timedelta(minutes=2)) - ) - except Exception: - stale_pending_without_tx = False - - if stale_pending_without_tx: - try: - cursor.execute(\"\"\" - UPDATE payments - SET payment_status = 'failed' - WHERE id = %s - AND payment_status = 'pending' - AND (txid IS NULL OR txid = '') - \"\"\", (pending_crypto_payment["id"],)) - conn.commit() - except Exception: - pass - pending_crypto_payment = None - else: - payment_id = str(pending_crypto_payment.get("id") or "").strip() - - if payment_id.isdigit(): -""" - -if old_block_1 not in text: - raise SystemExit("FAILED: block 1 not found") - -text = text.replace(old_block_1, new_block_1, 1) - -old_block_2 = """ 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") -""" - -new_block_2 = """ 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 - - if ( - str(pending_crypto_payment.get("payment_status") or "").lower() == "pending" - and not pending_crypto_payment.get("txid") - and pending_crypto_payment.get("lock_expired") - ): - try: - cursor.execute(\"\"\" - UPDATE payments - SET payment_status = 'failed' - WHERE id = %s - AND payment_status = 'pending' - AND (txid IS NULL OR txid = '') - \"\"\", (pending_crypto_payment["id"],)) - conn.commit() - except Exception: - pass - pending_crypto_payment = None - payment_id = "" - - received_dt = pending_crypto_payment.get("received_at") if pending_crypto_payment else None -""" - -if old_block_2 not in text: - raise SystemExit("FAILED: block 2 not found") - -text = text.replace(old_block_2, new_block_2, 1) - -p.write_text(text) -print("OK: pending payment logic patched") -PY - -python3 -m py_compile app.py -echo "PY_COMPILE_OK" - -sudo systemctl restart otb_billing -sudo systemctl status otb_billing --no-pager -l - -echo -echo "===== verify patched area =====" -sed -n '5220,5325p' app.py diff --git a/backend/fix_portal_indent.sh b/backend/fix_portal_indent.sh deleted file mode 100755 index c8e03cf..0000000 --- a/backend/fix_portal_indent.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -e - -python3 <<'PY' -from pathlib import Path -import re - -p = Path("app.py") -text = p.read_text() - -pattern = re.compile( - r"\n\s*# === KILL DEAD PENDING PAYMENT \(no txid \+ expired lock\) ===.*?" - r'print\("\[dead pending cleanup error\]", e\)\n', - re.DOTALL -) - -new_text, count = pattern.subn("\n", text, count=1) -if count == 0: - raise SystemExit("FAILED: broken dead-pending block not found") - -p.write_text(new_text) -print("OK: removed broken dead-pending block") -PY - -python3 -m py_compile app.py -echo "PY_COMPILE_OK" - -sudo systemctl restart otb_billing -sudo systemctl status otb_billing --no-pager -l diff --git a/backend/recover_otb_billing.sh b/backend/recover_otb_billing.sh deleted file mode 100755 index 5b4d4ec..0000000 --- a/backend/recover_otb_billing.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -set -e - -cd /home/def/otb_billing/backend || exit 1 - -GOOD_BAK="app.py.retry-email.20260327-023404.bak" - -echo "===== restoring known-good app.py =====" -cp "$GOOD_BAK" app.py - -echo -echo "===== compile check =====" -python3 -m py_compile app.py -echo "PY_COMPILE_OK" - -echo -echo "===== restart service =====" -sudo systemctl restart otb_billing - -echo -echo "===== service status =====" -sudo systemctl status otb_billing --no-pager -l - -echo -echo "===== last 20 journal lines =====" -sudo journalctl -u otb_billing.service -n 20 --no-pager diff --git a/build-backups/backup_fix_void_route_2026-03-08/app.py.bak b/build-backups/backup_fix_void_route_2026-03-08/app.py.bak deleted file mode 100644 index 271c1b0..0000000 --- a/build-backups/backup_fix_void_route_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/build-backups/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 deleted file mode 100644 index 2847cc7..0000000 --- a/build-backups/backup_fix_void_route_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/build-backups/backup_logo_support_2026-03-09/app.py.bak b/build-backups/backup_logo_support_2026-03-09/app.py.bak deleted file mode 100644 index 6e526ce..0000000 --- a/build-backups/backup_logo_support_2026-03-09/app.py.bak +++ /dev/null @@ -1,1583 +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_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/build-backups/backup_logo_support_2026-03-09/dashboard.html.bak b/build-backups/backup_logo_support_2026-03-09/dashboard.html.bak deleted file mode 100644 index 9b98c6c..0000000 --- a/build-backups/backup_logo_support_2026-03-09/dashboard.html.bak +++ /dev/null @@ -1,36 +0,0 @@ - - - -OTB Billing Dashboard - - - -

{{ 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/build-backups/backup_logo_support_2026-03-09/invoice_view.html.bak b/build-backups/backup_logo_support_2026-03-09/invoice_view.html.bak deleted file mode 100644 index 4b2d16f..0000000 --- a/build-backups/backup_logo_support_2026-03-09/invoice_view.html.bak +++ /dev/null @@ -1,202 +0,0 @@ - - - -Invoice {{ invoice.invoice_number }} - - - - -
- - -
-
-

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/build-backups/backup_logo_support_2026-03-09/settings.html.bak b/build-backups/backup_logo_support_2026-03-09/settings.html.bak deleted file mode 100644 index cd47e91..0000000 --- a/build-backups/backup_logo_support_2026-03-09/settings.html.bak +++ /dev/null @@ -1,169 +0,0 @@ - - - -Settings - - - - -

Settings / Config

- -

Home

- -
-
-
-

Business Identity

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

Tax Settings

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

Email / SMTP

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

Notes

-

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

-

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

-

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

-
-
- -
- -
-
- -{% include "footer.html" %} - - diff --git a/build-backups/backup_restore_email_layer_2026-03-09/app.py.bak b/build-backups/backup_restore_email_layer_2026-03-09/app.py.bak deleted file mode 100644 index 1051482..0000000 --- a/build-backups/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/build-backups/backup_restore_email_layer_2026-03-09/dashboard.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/dashboard.html.bak deleted file mode 100644 index 6e6a662..0000000 --- a/build-backups/backup_restore_email_layer_2026-03-09/dashboard.html.bak +++ /dev/null @@ -1,60 +0,0 @@ - - - -OTB Billing Dashboard - - - - -{% if app_settings.business_logo_url %} -
- -
-{% endif %} -

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

-{% 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 %} -{% if request.args.get('pkg_email_failed') == '1' %} -
- Accounting package email failed. Check SMTP settings or server log. -
-{% endif %} - -

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/build-backups/backup_restore_email_layer_2026-03-09/invoices_view.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/invoices_view.html.bak deleted file mode 100644 index d9e7557..0000000 --- a/build-backups/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/build-backups/backup_restore_email_layer_2026-03-09/revenue.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/revenue.html.bak deleted file mode 100644 index d22cb3f..0000000 --- a/build-backups/backup_restore_email_layer_2026-03-09/revenue.html.bak +++ /dev/null @@ -1,91 +0,0 @@ - - - -Revenue Report - - - - -

Revenue Report

-{% if request.args.get('email_sent') == '1' %} -
- Revenue report JSON emailed successfully. -
-{% endif %} -{% if request.args.get('email_failed') == '1' %} -
- Revenue report email failed. Check SMTP settings or server log. -
-{% endif %} -{% if request.args.get('email_failed') == '1' %} -
- Revenue report email failed. Check SMTP settings or server log. -
-{% endif %} - -

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/build-backups/backup_restore_email_layer_2026-03-09/settings.html.bak b/build-backups/backup_restore_email_layer_2026-03-09/settings.html.bak deleted file mode 100644 index aeabb82..0000000 --- a/build-backups/backup_restore_email_layer_2026-03-09/settings.html.bak +++ /dev/null @@ -1,202 +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
-
- - 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/shell-scripts/backpatch.sh b/shell-scripts/backpatch.sh deleted file mode 100755 index 99f1d07..0000000 --- a/shell-scripts/backpatch.sh +++ /dev/null @@ -1,49 +0,0 @@ -cd /home/def/otb_billing/backend || exit 1 -set -e - -STAMP="$(date +%Y%m%d-%H%M%S)" -cp app.py "app.py.fix-dead-pending.${STAMP}.bak" - -python3 <<'PY' -from pathlib import Path - -p = Path("app.py") -text = p.read_text() - -needle = "if payment_id.isdigit():" - -if needle not in text: - raise SystemExit("FAILED: anchor not found") - -inject = """ - # === KILL DEAD PENDING PAYMENT (no txid + expired lock) === - if pending_crypto_payment: - try: - txid = pending_crypto_payment.get("txid") - lock_expired = pending_crypto_payment.get("lock_expired") - - if (not txid) and lock_expired: - pending_crypto_payment = None - payment_id = "" - except Exception as e: - print("[dead pending cleanup error]", e) -""" - -text = text.replace(needle, inject + "\n" + needle, 1) - -p.write_text(text) -print("OK: dead pending cleanup injected") -PY - -echo "===== compile check =====" -python3 -m py_compile app.py && echo "PY_COMPILE_OK" - -echo "===== restart service =====" -sudo systemctl restart otb_billing - -echo "===== status =====" -sudo systemctl status otb_billing --no-pager -l - -echo -echo "DONE" -echo "Now refresh your invoice page" diff --git a/shell-scripts/email-retry-v2.sh b/shell-scripts/email-retry-v2.sh deleted file mode 100755 index 5bd629f..0000000 --- a/shell-scripts/email-retry-v2.sh +++ /dev/null @@ -1,60 +0,0 @@ -cd /home/def/otb_billing/backend || exit 1 - -STAMP="$(date +%Y%m%d-%H%M%S)" -cp app.py "app.py.retry-email.${STAMP}.bak" - -python3 <<'PY' -from pathlib import Path - -p = Path("app.py") -text = p.read_text() - -old = ''' 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 -''' - -new = ''' import time - - for attempt in range(3): - try: - 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"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") - if attempt < 2: - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False - except Exception: - return False -''' - -if old not in text: - raise SystemExit("FAILED: exact helper send block not found") - -text = text.replace(old, new, 1) -p.write_text(text) -print("OK: retry logic added to helper") -PY - -python3 -m py_compile app.py && echo "PY_COMPILE_OK" || echo "PY_COMPILE_FAILED" -sudo systemctl restart otb_billing -sudo systemctl status otb_billing --no-pager -l - diff --git a/shell-scripts/email-retry.sh b/shell-scripts/email-retry.sh deleted file mode 100755 index 02cfab3..0000000 --- a/shell-scripts/email-retry.sh +++ /dev/null @@ -1,51 +0,0 @@ -cd /home/def/otb_billing/backend || exit 1 - -STAMP="$(date +%Y%m%d-%H%M%S)" -cp app.py "app.py.retry-email.${STAMP}.bak" - -python3 <<'PY' -from pathlib import Path - -p = Path("app.py") -text = p.read_text() - -old = """ send_configured_email( - to_email=invoice_email_row.get("client_email"), - subject=subject, - html_body=html, - attachments=attachments - ) - return True - except Exception: - return False -""" - -new = """ import time - - for attempt in range(3): - try: - send_configured_email( - to_email=invoice_email_row.get("client_email"), - subject=subject, - html_body=html, - attachments=attachments - ) - return True - except Exception as e: - print(f"[email retry] attempt {attempt+1} failed: {e}") - time.sleep(2) - - print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") - return False -""" - -if old not in text: - raise SystemExit("FAILED: send email block not found") - -text = text.replace(old, new, 1) -p.write_text(text) -print("OK: retry logic added") -PY - -python3 -m py_compile app.py && echo "PY_COMPILE_OK" || echo "PY_COMPILE_FAILED" -sudo systemctl restart otb_billing diff --git a/shell-scripts/production-safe.sh b/shell-scripts/production-safe.sh deleted file mode 100755 index cd38a19..0000000 --- a/shell-scripts/production-safe.sh +++ /dev/null @@ -1,63 +0,0 @@ -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 -echo "===== update VERSION =====" -echo "${NEWVER}" > VERSION - -echo -echo "===== backup README =====" -cp README.md "README.md.bak.${NEWVER}" - -echo -echo "===== prepend README entry =====" -python3 <<'PY' -from pathlib import Path -from datetime import datetime - -p = Path("README.md") -old = p.read_text() - -entry = f"""## {datetime.now().strftime('%Y-%m-%d')} — v0.5.1 - -- Fixed crypto payment email auto-send path -- Fixed payment-received emails to attach the real invoice PDF -- Switched helper PDF generation to use the working invoice PDF route -- Added explorer link into the payment-received email body -- Improved invoice PDF payment section with time, TXID, wallet, and rate display -- Cleaned helper error handling back to safe non-debug behavior - -""" - -p.write_text(entry + "\n" + old) -print("README updated") -PY - -echo -echo "===== git add =====" -git add . - -echo -echo "===== git commit =====" -git commit -m "v0.5.1 - crypto email/pdf fixes and payment layout cleanup" - -echo -echo "===== git push =====" -git push - -echo -echo "===== build full zip backup =====" -cd /home/def || exit 1 -rm -f "${ZIPNAME}" -zip -r "${ZIPNAME}" otb_billing >/dev/null - -echo -echo "===== done =====" -echo "ZIP: /home/def/${ZIPNAME}" diff --git a/shell-scripts/update1.sh b/shell-scripts/update1.sh deleted file mode 100755 index 89d1ecb..0000000 --- a/shell-scripts/update1.sh +++ /dev/null @@ -1,51 +0,0 @@ -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/templates/invoices/view.html.square_button_20260313-055733.bak b/templates/invoices/view.html.square_button_20260313-055733.bak deleted file mode 100644 index ed0e537..0000000 --- a/templates/invoices/view.html.square_button_20260313-055733.bak +++ /dev/null @@ -1,256 +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" %} - -
- -

Payment Instructions

- -

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

- -

Credit Card (Square)
-Contact us for a secure Square payment link.

- -

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

- - - - diff --git a/templates/portal_invoice_detail.html.square_button_20260313-055733.bak b/templates/portal_invoice_detail.html.square_button_20260313-055733.bak deleted file mode 100644 index 6d1a444..0000000 --- a/templates/portal_invoice_detail.html.square_button_20260313-055733.bak +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - Invoice Detail - OutsideTheBox - - - - - -
-
-
-

Invoice Detail

-

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

-
- -
- -
-
-

Invoice

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

Status

- {% set s = (invoice.status or "")|lower %} - {% if s == "paid" %} - {{ invoice.status }} - {% elif s == "pending" %} - {{ invoice.status }} - {% elif s == "overdue" %} - {{ invoice.status }} - {% else %} - {{ invoice.status }} - {% endif %} -
-
-

Created

-
{{ invoice.created_at }}
-
-
-

Total

-
{{ invoice.total_amount }}
-
-
-

Paid

-
{{ invoice.amount_paid }}
-
-
-

Outstanding

-
{{ invoice.outstanding }}
-
-
- -

Invoice Items

- - - - - - - - - - - {% for item in items %} - - - - - - - {% else %} - - - - {% endfor %} - -
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.
- - {% if pdf_url %} - - {% endif %} -
- -{% include "footer.html" %} - -
- -

Payment Instructions

- -

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

- -

Credit Card (Square)
-Contact us for a secure Square payment link.

- -

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

- - - -