diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7ed4862..c8d7fd6 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,11 @@ +### v0.5.3 - 2026-03-27 21:25:28 + +- OTB Billing crypto payment flow is now stable end-to-end. +- Stale pending payment attempts no longer trap the invoice after quote expiry. +- Wallet flow, auto-retry email behavior, and portal invoice UX validated. +- Payment selector dropdown styling corrected for dark theme. +- Project is in a clean state for continued production hardening. + Project: OTB Billing Version: v0.4.3 Last Updated: 2026-03-13 diff --git a/README.md b/README.md index 7cb5833..f3c2fa7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +## v0.5.3 - 2026-03-27 21:25:11 + +- Fixed stale pending crypto payment lock issue so abandoned wallet attempts no longer trap the invoice +- Confirmed crypto quote expiry and refresh flow works cleanly +- Improved wallet/payment lifecycle stability for MetaMask and Rabby +- Added retry logic for payment-received emails +- Fixed dark-mode styling for the payment method dropdown +- General crypto payment UX and recovery cleanup + ## 2026-03-27 — v0.5.2 - Added retry logic for payment-received emails diff --git a/VERSION b/VERSION index b0c2058..4bc4a91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.5.2 +v0.5.3 diff --git a/backend/app.py b/backend/app.py index 59fec9c..7c10541 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1329,26 +1329,15 @@ support@outsidethebox.top "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 + 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 @@ -5175,7 +5164,35 @@ def portal_invoice_detail(invoice_id): 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() + 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(): cursor.execute(""" @@ -5206,7 +5223,26 @@ def portal_invoice_detail(invoice_id): pending_crypto_payment["lock_expires_at_iso"] = "" pending_crypto_payment["lock_expired"] = True - received_dt = pending_crypto_payment.get("received_at") + 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 received_dt and received_dt.tzinfo is None: received_dt = received_dt.replace(tzinfo=timezone.utc) diff --git a/backend/app.py.auto-expire-pending-v2.20260327-040852.bak b/backend/app.py.auto-expire-pending-v2.20260327-040852.bak new file mode 100644 index 0000000..45d4159 --- /dev/null +++ b/backend/app.py.auto-expire-pending-v2.20260327-040852.bak @@ -0,0 +1,6738 @@ +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 new file mode 100644 index 0000000..59fec9c --- /dev/null +++ b/backend/app.py.auto-expire-pending.20260327-035958.bak @@ -0,0 +1,6698 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session, Response +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +TERMS_VERSION = "v1.0" + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +SQUARE_ACCESS_TOKEN = os.getenv("SQUARE_ACCESS_TOKEN", "") +SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") +SQUARE_WEBHOOK_NOTIFICATION_URL = os.getenv("SQUARE_WEBHOOK_NOTIFICATION_URL", "") +SQUARE_API_BASE = "https://connect.squareup.com" +SQUARE_API_VERSION = "2026-01-22" +SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +def load_version(): + try: + with open(BASE_DIR / "VERSION", "r") as f: + return f.read().strip() + except Exception: + return "unknown" + +APP_VERSION = load_version() + +@app.context_processor +def inject_version(): + return {"app_version": APP_VERSION} + +@app.context_processor +def inject_app_settings(): + return {"app_settings": get_app_settings()} + +def fmt_local(dt_value): + if not dt_value: + return "" + if isinstance(dt_value, str): + try: + dt_value = datetime.fromisoformat(dt_value) + except ValueError: + return str(dt_value) + if dt_value.tzinfo is None: + dt_value = dt_value.replace(tzinfo=timezone.utc) + return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") + +def to_decimal(value): + if value is None or value == "": + return Decimal("0") + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return Decimal("0") + +def fmt_money(value, currency_code="CAD"): + amount = to_decimal(value) + if currency_code == "CAD": + return f"{amount:.2f}" + return f"{amount:.8f}" + +def payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +def square_amount_to_cents(value): + return int((to_decimal(value) * 100).quantize(Decimal("1"))) + +def create_square_payment_link_for_invoice(invoice_row, buyer_email=""): + if not SQUARE_ACCESS_TOKEN: + raise RuntimeError("Square access token is not configured") + + invoice_number = invoice_row.get("invoice_number") or f"INV-{invoice_row.get('id')}" + currency_code = invoice_row.get("currency_code") or "CAD" + amount_cents = square_amount_to_cents(invoice_row.get("total_amount") or "0") + location_id = "1TSPHT78106WX" + + payload = { + "idempotency_key": str(uuid.uuid4()), + "description": f"OTB Billing invoice {invoice_number}", + "quick_pay": { + "name": f"Invoice {invoice_number}", + "price_money": { + "amount": amount_cents, + "currency": currency_code + }, + "location_id": location_id + }, + "payment_note": f"Invoice {invoice_number}", + "checkout_options": { + "redirect_url": "https://portal.outsidethebox.top/portal" + } + } + + if buyer_email: + payload["pre_populated_data"] = { + "buyer_email": buyer_email + } + + req = urllib.request.Request( + f"{SQUARE_API_BASE}/v2/online-checkout/payment-links", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {SQUARE_ACCESS_TOKEN}", + "Square-Version": SQUARE_API_VERSION, + "Content-Type": "application/json" + }, + method="POST" + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Square payment link creation failed: {e.code} {body}") + + payment_link = (data or {}).get("payment_link") or {} + url = payment_link.get("url") + if not url: + raise RuntimeError(f"Square payment link response missing URL: {data}") + + return url + + +def square_signature_is_valid(signature_header, raw_body, notification_url): + if not SQUARE_WEBHOOK_SIGNATURE_KEY or not signature_header: + return False + message = notification_url.encode("utf-8") + raw_body + digest = hmac.new( + SQUARE_WEBHOOK_SIGNATURE_KEY.encode("utf-8"), + message, + hashlib.sha256 + ).digest() + computed_signature = base64.b64encode(digest).decode("utf-8") + return hmac.compare_digest(computed_signature, signature_header) + +def append_square_webhook_log(entry): + try: + log_path = Path(SQUARE_WEBHOOK_LOG) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + +def generate_portal_access_code(): + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + groups = [] + for _ in range(3): + groups.append("".join(secrets.choice(alphabet) for _ in range(4))) + return "-".join(groups) + +def refresh_overdue_invoices(): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE invoices + SET status = 'overdue' + WHERE due_at IS NOT NULL + AND due_at < UTC_TIMESTAMP() + AND status IN ('pending', 'partial') + """) + conn.commit() + conn.close() + + +def send_payment_received_email(invoice_id, payment_amount_display): + try: + notify_conn = get_db_connection() + notify_cursor = notify_conn.cursor(dictionary=True) + + notify_cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.currency_code, + c.company_name, + c.contact_name, + c.email + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.id = %s + LIMIT 1 + """, (invoice_id,)) + invoice_email_row = notify_cursor.fetchone() + notify_conn.close() + + if not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + 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 new file mode 100644 index 0000000..59fec9c --- /dev/null +++ b/backend/app.py.auto-expire-pending.20260327-040203.bak @@ -0,0 +1,6698 @@ +import os +from dotenv import load_dotenv +load_dotenv() + +from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session, Response +from db import get_db_connection +from utils import generate_client_code, generate_service_code +from datetime import datetime, timezone, date, timedelta +from zoneinfo import ZoneInfo +from decimal import Decimal, InvalidOperation +from pathlib import Path +from email.message import EmailMessage +from dateutil.relativedelta import relativedelta + +from io import BytesIO, StringIO +import csv +import json +import hmac +import hashlib +import base64 +import urllib.request +import urllib.error +import urllib.parse +import uuid +import re +import math +import zipfile +import smtplib +import secrets +import threading +import time +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from werkzeug.security import generate_password_hash, check_password_hash +from health import register_health_routes + +app = Flask( + __name__, + template_folder="../templates", + static_folder="../static", +) +app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection + +TERMS_VERSION = "v1.0" + +LOCAL_TZ = ZoneInfo("America/Toronto") + +BASE_DIR = Path(__file__).resolve().parent.parent + +app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") +OTB_ADMIN_USER = os.getenv("OTB_ADMIN_USER", "def") +OTB_ADMIN_PASS = os.getenv("OTB_ADMIN_PASS", "ChangeThis-OTB-Admin-Now!") +SQUARE_ACCESS_TOKEN = os.getenv("SQUARE_ACCESS_TOKEN", "") +SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") +SQUARE_WEBHOOK_NOTIFICATION_URL = os.getenv("SQUARE_WEBHOOK_NOTIFICATION_URL", "") +SQUARE_API_BASE = "https://connect.squareup.com" +SQUARE_API_VERSION = "2026-01-22" +SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") +ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") +CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") +RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") +RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") +RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") + +RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") +RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") +RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") + +RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org") +RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org") +RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com") +RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com") + +CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) +CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) +CRYPTO_WATCHER_STARTED = False + + + + + +def admin_required(): + if session.get("admin_authenticated"): + return None + session["admin_next"] = request.path + return redirect("/admin/login") + + +def load_version(): + try: + with open(BASE_DIR / "VERSION", "r") as f: + return f.read().strip() + except Exception: + return "unknown" + +APP_VERSION = load_version() + +@app.context_processor +def inject_version(): + return {"app_version": APP_VERSION} + +@app.context_processor +def inject_app_settings(): + return {"app_settings": get_app_settings()} + +def fmt_local(dt_value): + if not dt_value: + return "" + if isinstance(dt_value, str): + try: + dt_value = datetime.fromisoformat(dt_value) + except ValueError: + return str(dt_value) + if dt_value.tzinfo is None: + dt_value = dt_value.replace(tzinfo=timezone.utc) + return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") + +def to_decimal(value): + if value is None or value == "": + return Decimal("0") + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return Decimal("0") + +def fmt_money(value, currency_code="CAD"): + amount = to_decimal(value) + if currency_code == "CAD": + return f"{amount:.2f}" + return f"{amount:.8f}" + +def payment_method_label(method, currency=None): + method_key = str(method or "").strip().lower() + currency_key = str(currency or "").strip().upper() + + if method_key == "square": + return "Square" + if method_key == "etransfer": + return "e-Transfer" + if method_key == "cash": + return "Cash" + if method_key == "other": + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}: + return currency_key + return "Other" + if method_key == "crypto_etho": + return "ETHO" + if method_key == "crypto_egaz": + return "EGAZ" + if method_key == "crypto_alt": + return "ALT" + + if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}: + return currency_key + + return method or "Unknown" + +def get_invoice_payments(invoice_id): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + id, + payment_method, + payment_currency, + payment_amount, + cad_value_at_payment, + reference, + sender_name, + txid, + wallet_address, + payment_status, + confirmations, + confirmation_required, + received_at, + created_at, + notes + FROM payments + WHERE invoice_id = %s + ORDER BY COALESCE(received_at, created_at) ASC, id ASC + """, (invoice_id,)) + rows = cursor.fetchall() + conn.close() + + out = [] + for row in rows: + item = dict(row) + item["payment_method_label"] = payment_method_label( + item.get("payment_method"), + item.get("payment_currency"), + ) + item["payment_amount_display"] = fmt_money( + item.get("payment_amount"), + item.get("payment_currency") or "CAD", + ) + item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD") + item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at")) + out.append(item) + return out + +def normalize_oracle_datetime(value): + if not value: + return None + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return None + +def ensure_invoice_quote_columns(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'invoices' + """) + existing = {row["COLUMN_NAME"] for row in cursor.fetchall()} + + wanted = { + "quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status", + "quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount", + "quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency", + "oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at" + } + + exec_cursor = conn.cursor() + changed = False + for column_name, ddl in wanted.items(): + if column_name not in existing: + exec_cursor.execute(ddl) + changed = True + + if changed: + conn.commit() + conn.close() + +def fetch_oracle_quote_snapshot(currency_code, total_amount): + if str(currency_code or "").upper() != "CAD": + return None + + try: + amount_value = Decimal(str(total_amount)) + if amount_value <= 0: + return None + except (InvalidOperation, ValueError): + return None + + try: + qs = urllib.parse.urlencode({ + "fiat": "CAD", + "amount": format(amount_value, "f"), + }) + req = urllib.request.Request( + f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}", + headers={ + "Accept": "application/json", + "User-Agent": "otb-billing-oracle/0.1" + }, + method="GET" + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not isinstance(data, dict) or not isinstance(data.get("quotes"), list): + return None + + return { + "oracle_url": ORACLE_BASE_URL.rstrip("/"), + "quoted_at": data.get("quoted_at"), + "expires_at": data.get("expires_at"), + "ttl_seconds": data.get("ttl_seconds"), + "source_status": data.get("source_status"), + "fiat": data.get("fiat") or "CAD", + "amount": format(amount_value, "f"), + "quotes": data.get("quotes", []), + } + except Exception: + return None + +def get_invoice_crypto_options(invoice): + oracle_quote = invoice.get("oracle_quote") or {} + raw_quotes = oracle_quote.get("quotes") or [] + + option_map = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "label": "USDC (Arbitrum)", + "payment_currency": "USDC", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 42161, + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "label": "ETH (Ethereum)", + "payment_currency": "ETH", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1, + "decimals": 18, + "token_contract": None, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "label": "ETHO (Etho)", + "payment_currency": "ETHO", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "native", + "chain_id": 1313114, + "decimals": 18, + "token_contract": None, + "rpc_urls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "chain_add_params": { + "chainId": "0x14095a", + "chainName": "Etho Protocol", + "nativeCurrency": { + "name": "Etho Protocol", + "symbol": "ETHO", + "decimals": 18 + }, + "rpcUrls": [RPC_ETHO_URL, RPC_ETHO_URL_2], + "blockExplorerUrls": ["https://explorer.ethoprotocol.com"] + }, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "label": "ETI (Etica)", + "payment_currency": "ETI", + "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, + "wallet_capable": True, + "asset_type": "token", + "chain_id": 61803, + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "chain_add_params": { + "chainId": "0xf16b", + "chainName": "Etica", + "nativeCurrency": { + "name": "Etica Gas", + "symbol": "EGAZ", + "decimals": 18 + }, + "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2], + "blockExplorerUrls": ["https://explorer.etica-stats.org"] + }, + }, + } + + options = [] + for q in raw_quotes: + symbol = str(q.get("symbol") or "").upper() + if symbol not in option_map: + continue + if not q.get("display_amount"): + continue + + opt = dict(option_map[symbol]) + opt["display_amount"] = q.get("display_amount") + opt["crypto_amount"] = q.get("crypto_amount") + opt["price_cad"] = q.get("price_cad") + opt["recommended"] = bool(q.get("recommended")) + opt["available"] = bool(q.get("available")) + opt["reason"] = q.get("reason") + options.append(opt) + + options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol"))) + return options + +def get_rpc_urls_for_chain(chain_name): + chain = str(chain_name or "").lower() + if chain == "ethereum": + return [u for u in [RPC_ETHEREUM_URL, RPC_ETHEREUM_URL_2, RPC_ETHEREUM_URL_3] if u] + if chain == "arbitrum": + return [u for u in [RPC_ARBITRUM_URL, RPC_ARBITRUM_URL_2, RPC_ARBITRUM_URL_3] if u] + if chain == "etica": + return [u for u in [RPC_ETICA_URL, RPC_ETICA_URL_2] if u] + if chain == "etho": + return [u for u in [RPC_ETHO_URL, RPC_ETHO_URL_2] if u] + return [] + +def rpc_call_any(rpc_urls, method, params): + last_error = None + + for rpc_url in rpc_urls: + try: + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }).encode("utf-8") + + req = urllib.request.Request( + rpc_url, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "otb-billing-rpc/0.1", + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if isinstance(data, dict) and data.get("error"): + raise RuntimeError(str(data["error"])) + + return { + "rpc_url": rpc_url, + "result": (data or {}).get("result"), + } + except Exception as err: + last_error = err + + if last_error: + raise last_error + raise RuntimeError("No RPC URLs configured") + + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def _hex_to_int(value): + if value is None: + return 0 + text = str(value).strip() + if not text: + return 0 + if text.startswith("0x"): + return int(text, 16) + return int(text) + +def fetch_rpc_balance(chain_name, wallet_address): + rpc_urls = get_rpc_urls_for_chain(chain_name) + if not rpc_urls or not wallet_address: + return None + try: + resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) + result = (resp or {}).get("result") + if result is None: + return None + return { + "rpc_url": resp.get("rpc_url"), + "balance_wei": _hex_to_int(result), + } + except Exception: + return None + +def verify_expected_tx_for_payment(option, tx): + if not option or not tx: + raise RuntimeError("missing transaction data") + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = _hex_to_int(tx.get("value") or "0x0") + + if tx_to != wallet_to: + raise RuntimeError("transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("transaction value does not match frozen quote amount") + + return True + + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("token contract does not match expected asset contract") + + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("transaction input is not a supported ERC20 transfer") + + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("token transfer recipient does not match payment wallet") + + if int(parsed["amount"]) != expected_units: + raise RuntimeError("token transfer amount does not match frozen quote amount") + + return True + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + return mapping.get(currency) + +def reconcile_pending_crypto_payment(payment_row): + tx_hash = str(payment_row.get("txid") or "").strip() + if not tx_hash or not tx_hash.startswith("0x"): + return {"state": "no_tx_hash"} + + option = get_processing_crypto_option(payment_row) + if not option: + return {"state": "unsupported_currency"} + + created_dt = payment_row.get("updated_at") or payment_row.get("created_at") + if created_dt and created_dt.tzinfo is None: + created_dt = created_dt.replace(tzinfo=timezone.utc) + if not created_dt: + created_dt = datetime.now(timezone.utc) + + age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + return {"state": "no_rpc"} + + tx_hit = None + receipt_hit = None + tx_rpc = None + receipt_rpc = None + + for rpc_url in rpc_urls: + try: + tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx_obj = tx_resp.get("result") + if tx_obj: + verify_expected_tx_for_payment(option, tx_obj) + tx_hit = tx_obj + tx_rpc = tx_resp.get("rpc_url") + break + except Exception: + continue + + if tx_hit: + for rpc_url in rpc_urls: + try: + receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) + receipt_obj = receipt_resp.get("result") + if receipt_obj: + receipt_hit = receipt_obj + receipt_rpc = receipt_resp.get("rpc_url") + break + except Exception: + continue + + balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, invoice_id, notes, payment_status + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_row["id"],)) + live_payment = cursor.fetchone() + + if not live_payment: + conn.close() + return {"state": "payment_missing"} + + notes = live_payment.get("notes") or "" + + if tx_hit and receipt_hit: + receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") + confirmations = 0 + block_number = receipt_hit.get("blockNumber") + if block_number: + try: + bn = _hex_to_int(block_number) + latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) + latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") + if latest_bn >= bn: + confirmations = (latest_bn - bn) + 1 + except Exception: + confirmations = 1 + + if receipt_status == 1: + notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") + if balance_info: + notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = %s, + confirmation_required = 1, + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, ( + confirmations or 1, + notes, + payment_row["id"] + )) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + try: + payment_amount_display = f"{to_decimal(payment_row.get('cad_value_at_payment')):.2f} CAD" + send_payment_received_email(payment_row["invoice_id"], payment_amount_display) + except Exception: + pass + + return {"state": "confirmed", "confirmations": confirmations or 1} + + notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "failed_receipt"} + + if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: + notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" + if balance_info: + notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + if notes_line not in notes: + notes = append_payment_note(notes, notes_line) + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + return {"state": "processing", "age_seconds": age_seconds} + + fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" + if balance_info: + fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" + notes = append_payment_note(notes, fail_line) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (notes, payment_row["id"])) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(payment_row["invoice_id"]) + except Exception: + pass + + return {"state": "timeout", "age_seconds": age_seconds} + +def _to_base_units(amount_text, decimals): + amount_dec = Decimal(str(amount_text)) + scale = Decimal(10) ** int(decimals) + return int((amount_dec * scale).quantize(Decimal("1"))) + +def _strip_0x(value): + return str(value or "").lower().replace("0x", "") + +def parse_erc20_transfer_input(input_data): + data = _strip_0x(input_data) + if not data.startswith("a9059cbb"): + return None + if len(data) < 8 + 64 + 64: + return None + to_chunk = data[8:72] + amount_chunk = data[72:136] + to_addr = "0x" + to_chunk[-40:] + amount_int = int(amount_chunk, 16) + return { + "to": to_addr, + "amount": amount_int, + } + +def verify_wallet_transaction(option, tx_hash): + rpc_urls = get_rpc_urls_for_chain(option.get("chain")) + if not rpc_urls: + raise RuntimeError("No RPC configured for chain") + + seen_result = None + last_not_found = False + + for rpc_url in rpc_urls: + try: + rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) + tx = rpc_resp.get("result") + if not tx: + last_not_found = True + continue + + wallet_to = str(option.get("wallet_address") or "").lower() + expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) + + if option.get("asset_type") == "native": + tx_to = str(tx.get("to") or "").lower() + tx_value = int(tx.get("value") or "0x0", 16) + if tx_to != wallet_to: + raise RuntimeError("Transaction destination does not match payment wallet") + if tx_value != expected_units: + raise RuntimeError("Transaction value does not match frozen quote amount") + else: + tx_to = str(tx.get("to") or "").lower() + contract = str(option.get("token_contract") or "").lower() + if tx_to != contract: + raise RuntimeError("Token contract does not match expected asset contract") + parsed = parse_erc20_transfer_input(tx.get("input") or "") + if not parsed: + raise RuntimeError("Transaction input is not a supported ERC20 transfer") + if str(parsed["to"]).lower() != wallet_to: + raise RuntimeError("Token transfer recipient does not match payment wallet") + if int(parsed["amount"]) != expected_units: + raise RuntimeError("Token transfer amount does not match frozen quote amount") + + seen_result = { + "rpc_url": rpc_url, + "tx": tx, + } + break + except Exception: + continue + + if not seen_result: + if last_not_found: + raise RuntimeError("Transaction hash not found on any configured RPC") + raise RuntimeError("Unable to verify transaction on configured RPC pool") + + return seen_result + + +def get_processing_crypto_option(payment_row): + currency = str(payment_row.get("payment_currency") or "").upper() + amount_text = str(payment_row.get("payment_amount") or "0") + wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS + + mapping = { + "USDC": { + "symbol": "USDC", + "chain": "arbitrum", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 6, + "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "display_amount": amount_text, + }, + "ETH": { + "symbol": "ETH", + "chain": "ethereum", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETHO": { + "symbol": "ETHO", + "chain": "etho", + "wallet_address": wallet_address, + "asset_type": "native", + "decimals": 18, + "token_contract": None, + "display_amount": amount_text, + }, + "ETI": { + "symbol": "ETI", + "chain": "etica", + "wallet_address": wallet_address, + "asset_type": "token", + "decimals": 18, + "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", + "display_amount": amount_text, + }, + } + + return mapping.get(currency) + +def append_payment_note(existing_notes, extra_line): + base = (existing_notes or "").rstrip() + if not base: + return extra_line.strip() + return base + "\n" + extra_line.strip() + +def mark_crypto_payment_failed(payment_id, reason): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT invoice_id, notes + FROM payments + WHERE id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + f"[crypto watcher] failed: {reason}" + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'failed', + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + conn.close() + + try: + recalc_invoice_totals(row["invoice_id"]) + except Exception: + pass + +def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT p.*, i.client_id AS invoice_client_id, i.invoice_number, + i.total_amount, i.amount_paid, i.status AS invoice_status, + c.email AS client_email, c.company_name, c.contact_name + FROM payments p + JOIN invoices i ON i.id = p.invoice_id + LEFT JOIN clients c ON c.id = i.client_id + WHERE p.id = %s + LIMIT 1 + """, (payment_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return + + if str(row.get("payment_status") or "").lower() == "confirmed": + conn.close() + return + + new_notes = append_payment_note( + row.get("notes"), + "[crypto watcher] confirmed via %s txid=%s" % (rpc_url, row.get("txid") or "") + ) + + update_cursor = conn.cursor() + update_cursor.execute(""" + UPDATE payments + SET payment_status = 'confirmed', + confirmations = COALESCE(confirmation_required, 1), + confirmation_required = COALESCE(confirmations, 1), + received_at = COALESCE(received_at, UTC_TIMESTAMP()), + notes = %s + WHERE id = %s + """, (new_notes, payment_id)) + conn.commit() + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + { + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper()), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + paid_via_cursor = conn.cursor() + paid_via_cursor.execute(""" + UPDATE invoices + SET paid_via_method = %s, + paid_via_asset = %s, + paid_via_network = %s + WHERE id = %s + """, ( + "crypto", + str(row.get("payment_currency") or "").upper() or None, + ({ + "ETH": "ethereum", + "ETHO": "etho", + "ETI": "etica", + "USDC": "arbitrum", + }.get(str(row.get("payment_currency") or "").upper())), + invoice_id + )) + conn.commit() + except Exception: + pass + + try: + recalc_invoice_totals(invoice_id) + except Exception: + pass + + refresh_cursor = conn.cursor(dictionary=True) + refresh_cursor.execute(""" + SELECT id, client_id, invoice_number, total_amount, amount_paid, status + FROM invoices + WHERE id = %s + LIMIT 1 + """, (invoice_id,)) + invoice = refresh_cursor.fetchone() or {} + + try: + from decimal import Decimal + total_dec = Decimal(str(invoice.get("total_amount") or "0")) + paid_dec = Decimal(str(invoice.get("amount_paid") or "0")) + + overpayment_dec = paid_dec - total_dec + if overpayment_dec > Decimal("0"): + credit_note = "Overpayment from invoice %s (crypto payment_id=%s)" % ( + invoice.get("invoice_number") or invoice_id, + payment_id + ) + + check_cursor = conn.cursor(dictionary=True) + check_cursor.execute(""" + SELECT id FROM credit_ledger + WHERE client_id = %s AND notes = %s + LIMIT 1 + """, (invoice.get("client_id"), credit_note)) + + if not check_cursor.fetchone(): + credit_cursor = conn.cursor() + credit_cursor.execute(""" + INSERT INTO credit_ledger + (client_id, entry_type, amount, currency_code, notes) + VALUES (%s, %s, %s, %s, %s) + """, ( + invoice.get("client_id"), + "credit", + str(overpayment_dec), + "CAD", + credit_note + )) + conn.commit() + except Exception: + pass + + try: + if str(invoice.get("status") or "").lower() == "paid": + recipient = (row.get("client_email") or "").strip() + if recipient: + subject = "Invoice %s Paid (Crypto)" % (invoice.get("invoice_number") or invoice_id) + + body = "Your payment has been received and confirmed.\n\n" + body += "Invoice: %s\n" % (invoice.get("invoice_number") or invoice_id) + body += "Amount: %s %s\n" % (row.get("payment_amount"), row.get("payment_currency")) + body += "TXID: %s\n\n" % (row.get("txid") or "") + body += "Thank you for your business.\n" + + send_configured_email( + to_email=recipient, + subject=subject, + body=body, + attachments=None, + email_type="invoice_paid_crypto", + invoice_id=invoice_id + ) + except Exception: + pass + + conn.close() +def watch_pending_crypto_payments_once(): + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT + id, + invoice_id, + client_id, + payment_currency, + payment_amount, + cad_value_at_payment, + wallet_address, + txid, + payment_status, + created_at, + updated_at, + notes + FROM payments + WHERE payment_status = 'pending' + AND txid IS NOT NULL + AND txid <> '' + AND notes LIKE '%%portal_crypto_intent:%%' + ORDER BY id ASC + """) + pending_rows = cursor.fetchall() + conn.close() + + now_utc = datetime.now(timezone.utc) + + for row in pending_rows: + option = get_processing_crypto_option(row) + if not option: + continue + + submitted_at = row.get("updated_at") or row.get("created_at") + if submitted_at and submitted_at.tzinfo is None: + submitted_at = submitted_at.replace(tzinfo=timezone.utc) + + if not submitted_at: + submitted_at = now_utc + + age_seconds = (now_utc - submitted_at).total_seconds() + + if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS: + mark_crypto_payment_failed(row["id"], "processing timeout exceeded") + continue + + try: + verified = verify_wallet_transaction(option, str(row.get("txid") or "").strip()) + mark_crypto_payment_confirmed(row["id"], row["invoice_id"], verified.get("rpc_url") or "rpc") + except Exception as err: + msg = str(err or "") + lower = msg.lower() + retryable = ( + "not found on any configured rpc" in lower + or "not found on rpc" in lower + or "unable to verify transaction on configured rpc pool" in lower + ) + if retryable: + continue + # non-retryable verification problems get marked failed immediately + mark_crypto_payment_failed(row["id"], msg) + +def crypto_payment_watcher_loop(): + while True: + try: + watch_pending_crypto_payments_once() + except Exception as err: + try: + print(f"[crypto-watcher] {err}") + except Exception: + pass + time.sleep(max(5, CRYPTO_WATCH_INTERVAL_SECONDS)) + +def start_crypto_payment_watcher(): + # Disabled in favor of the systemd-managed crypto_reconciliation_worker.py + # This avoids duplicate payment checks / duplicate notifications. + return + +def square_amount_to_cents(value): + return int((to_decimal(value) * 100).quantize(Decimal("1"))) + +def create_square_payment_link_for_invoice(invoice_row, buyer_email=""): + if not SQUARE_ACCESS_TOKEN: + raise RuntimeError("Square access token is not configured") + + invoice_number = invoice_row.get("invoice_number") or f"INV-{invoice_row.get('id')}" + currency_code = invoice_row.get("currency_code") or "CAD" + amount_cents = square_amount_to_cents(invoice_row.get("total_amount") or "0") + location_id = "1TSPHT78106WX" + + payload = { + "idempotency_key": str(uuid.uuid4()), + "description": f"OTB Billing invoice {invoice_number}", + "quick_pay": { + "name": f"Invoice {invoice_number}", + "price_money": { + "amount": amount_cents, + "currency": currency_code + }, + "location_id": location_id + }, + "payment_note": f"Invoice {invoice_number}", + "checkout_options": { + "redirect_url": "https://portal.outsidethebox.top/portal" + } + } + + if buyer_email: + payload["pre_populated_data"] = { + "buyer_email": buyer_email + } + + req = urllib.request.Request( + f"{SQUARE_API_BASE}/v2/online-checkout/payment-links", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {SQUARE_ACCESS_TOKEN}", + "Square-Version": SQUARE_API_VERSION, + "Content-Type": "application/json" + }, + method="POST" + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Square payment link creation failed: {e.code} {body}") + + payment_link = (data or {}).get("payment_link") or {} + url = payment_link.get("url") + if not url: + raise RuntimeError(f"Square payment link response missing URL: {data}") + + return url + + +def square_signature_is_valid(signature_header, raw_body, notification_url): + if not SQUARE_WEBHOOK_SIGNATURE_KEY or not signature_header: + return False + message = notification_url.encode("utf-8") + raw_body + digest = hmac.new( + SQUARE_WEBHOOK_SIGNATURE_KEY.encode("utf-8"), + message, + hashlib.sha256 + ).digest() + computed_signature = base64.b64encode(digest).decode("utf-8") + return hmac.compare_digest(computed_signature, signature_header) + +def append_square_webhook_log(entry): + try: + log_path = Path(SQUARE_WEBHOOK_LOG) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + +def generate_portal_access_code(): + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + groups = [] + for _ in range(3): + groups.append("".join(secrets.choice(alphabet) for _ in range(4))) + return "-".join(groups) + +def refresh_overdue_invoices(): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE invoices + SET status = 'overdue' + WHERE due_at IS NOT NULL + AND due_at < UTC_TIMESTAMP() + AND status IN ('pending', 'partial') + """) + conn.commit() + conn.close() + + +def send_payment_received_email(invoice_id, payment_amount_display): + try: + notify_conn = get_db_connection() + notify_cursor = notify_conn.cursor(dictionary=True) + + notify_cursor.execute(""" + SELECT + i.id, + i.invoice_number, + i.status, + i.total_amount, + i.amount_paid, + i.currency_code, + c.company_name, + c.contact_name, + c.email + FROM invoices i + JOIN clients c ON i.client_id = c.id + WHERE i.id = %s + LIMIT 1 + """, (invoice_id,)) + invoice_email_row = notify_cursor.fetchone() + notify_conn.close() + + if not invoice_email_row or not invoice_email_row.get("email"): + return False + + client_name = ( + invoice_email_row.get("contact_name") + or invoice_email_row.get("company_name") + or invoice_email_row.get("email") + ) + invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" + + 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.fix-dead-pending.20260327-042733.bak b/backend/app.py.fix-dead-pending.20260327-042733.bak new file mode 100644 index 0000000..3ce26d7 --- /dev/null +++ b/backend/app.py.fix-dead-pending.20260327-042733.bak @@ -0,0 +1,6739 @@ +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.pending-payment-logic.20260327-184101.bak b/backend/app.py.pending-payment-logic.20260327-184101.bak new file mode 100644 index 0000000..5d1aa55 --- /dev/null +++ b/backend/app.py.pending-payment-logic.20260327-184101.bak @@ -0,0 +1,6687 @@ +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-indent-fix.20260327-042939.bak b/backend/app.py.pre-indent-fix.20260327-042939.bak new file mode 100644 index 0000000..b6cf98e --- /dev/null +++ b/backend/app.py.pre-indent-fix.20260327-042939.bak @@ -0,0 +1,6752 @@ +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.safe-pending-fix.20260327-043630.bak b/backend/app.py.safe-pending-fix.20260327-043630.bak new file mode 100644 index 0000000..5d1aa55 --- /dev/null +++ b/backend/app.py.safe-pending-fix.20260327-043630.bak @@ -0,0 +1,6687 @@ +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 new file mode 100755 index 0000000..b4850b4 --- /dev/null +++ b/backend/auto_expire_patch.sh @@ -0,0 +1,74 @@ +#!/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 new file mode 100755 index 0000000..7d7430f --- /dev/null +++ b/backend/auto_expire_patch_v2.sh @@ -0,0 +1,44 @@ +#!/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 new file mode 100755 index 0000000..f7ce1d8 --- /dev/null +++ b/backend/fix_pending_payment_logic.sh @@ -0,0 +1,116 @@ +#!/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 new file mode 100755 index 0000000..c8e03cf --- /dev/null +++ b/backend/fix_portal_indent.sh @@ -0,0 +1,29 @@ +#!/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 new file mode 100755 index 0000000..5b4d4ec --- /dev/null +++ b/backend/recover_otb_billing.sh @@ -0,0 +1,26 @@ +#!/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/shell-scripts/backpatch.sh b/shell-scripts/backpatch.sh new file mode 100755 index 0000000..99f1d07 --- /dev/null +++ b/shell-scripts/backpatch.sh @@ -0,0 +1,49 @@ +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/static/css/fix_dropdown_darkmode.css b/static/css/fix_dropdown_darkmode.css new file mode 100644 index 0000000..318300d --- /dev/null +++ b/static/css/fix_dropdown_darkmode.css @@ -0,0 +1,15 @@ +.pay-selector { + background: #0f172a !important; + color: #e8eefc !important; + border: 1px solid rgba(255,255,255,0.2) !important; +} + +.pay-selector option { + background: #0f172a; + color: #e8eefc; +} + +.pay-selector option:checked { + background: #2563eb; + color: #ffffff; +} diff --git a/static/css/style.css b/static/css/style.css index 1e26b43..57fa5f2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1093,4 +1093,18 @@ body{ font-size:11px; line-height:1.25; } -} \ No newline at end of file +}.pay-selector { + background: #0f172a !important; + color: #e8eefc !important; + border: 1px solid rgba(255,255,255,0.2) !important; +} + +.pay-selector option { + background: #0f172a; + color: #e8eefc; +} + +.pay-selector option:checked { + background: #2563eb; + color: #ffffff; +}