import os from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session, Response from db import get_db_connection from utils import generate_client_code, generate_service_code from datetime import datetime, timezone, date, timedelta from zoneinfo import ZoneInfo from decimal import Decimal, InvalidOperation from pathlib import Path from email.message import EmailMessage from dateutil.relativedelta import relativedelta from io import BytesIO, StringIO import csv import json import hmac import hashlib import base64 import urllib.request import urllib.error import urllib.parse import uuid import re import math import zipfile import smtplib import secrets import threading import time from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader from werkzeug.security import generate_password_hash, check_password_hash from health import register_health_routes app = Flask( __name__, template_folder="../templates", static_folder="../static", ) app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection LOCAL_TZ = ZoneInfo("America/Toronto") BASE_DIR = Path(__file__).resolve().parent.parent app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me") SQUARE_ACCESS_TOKEN = os.getenv("SQUARE_ACCESS_TOKEN", "") SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") SQUARE_WEBHOOK_NOTIFICATION_URL = os.getenv("SQUARE_WEBHOOK_NOTIFICATION_URL", "") SQUARE_API_BASE = "https://connect.squareup.com" SQUARE_API_VERSION = "2026-01-22" SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log") ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top") CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5") RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com") RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth") RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org") RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com") RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum") RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc") CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180")) CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30")) CRYPTO_WATCHER_STARTED = False def load_version(): try: with open(BASE_DIR / "VERSION", "r") as f: return f.read().strip() except Exception: return "unknown" APP_VERSION = load_version() @app.context_processor def inject_version(): return {"app_version": APP_VERSION} @app.context_processor def inject_app_settings(): return {"app_settings": get_app_settings()} def fmt_local(dt_value): if not dt_value: return "" if isinstance(dt_value, str): try: dt_value = datetime.fromisoformat(dt_value) except ValueError: return str(dt_value) if dt_value.tzinfo is None: dt_value = dt_value.replace(tzinfo=timezone.utc) return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p") def to_decimal(value): if value is None or value == "": return Decimal("0") try: return Decimal(str(value)) except (InvalidOperation, ValueError): return Decimal("0") def fmt_money(value, currency_code="CAD"): amount = to_decimal(value) if currency_code == "CAD": return f"{amount:.2f}" return f"{amount:.8f}" def 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": False, "asset_type": "native", "chain_id": None, "decimals": 18, "token_contract": None, }, "ETI": { "symbol": "ETI", "chain": "etica", "label": "ETI (Etica)", "payment_currency": "ETI", "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS, "wallet_capable": False, "asset_type": "token", "chain_id": None, "decimals": 18, "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", }, } 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] return [] def rpc_call_any(rpc_urls, method, params): last_error = None for rpc_url in rpc_urls: try: payload = json.dumps({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, }).encode("utf-8") req = urllib.request.Request( rpc_url, data=payload, headers={ "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "otb-billing-rpc/0.1", }, method="POST" ) with urllib.request.urlopen(req, timeout=20) as resp: data = json.loads(resp.read().decode("utf-8")) if isinstance(data, dict) and data.get("error"): raise RuntimeError(str(data["error"])) return { "rpc_url": rpc_url, "result": (data or {}).get("result"), } except Exception as err: last_error = err if last_error: raise last_error raise RuntimeError("No RPC URLs configured") def append_payment_note(existing_notes, extra_line): base = (existing_notes or "").rstrip() if not base: return extra_line.strip() return base + "\n" + extra_line.strip() def _hex_to_int(value): if value is None: return 0 text = str(value).strip() if not text: return 0 if text.startswith("0x"): return int(text, 16) return int(text) def fetch_rpc_balance(chain_name, wallet_address): rpc_urls = get_rpc_urls_for_chain(chain_name) if not rpc_urls or not wallet_address: return None try: resp = rpc_call_any(rpc_urls, "eth_getBalance", [wallet_address, "latest"]) result = (resp or {}).get("result") if result is None: return None return { "rpc_url": resp.get("rpc_url"), "balance_wei": _hex_to_int(result), } except Exception: return None def verify_expected_tx_for_payment(option, tx): if not option or not tx: raise RuntimeError("missing transaction data") wallet_to = str(option.get("wallet_address") or "").lower() expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) if option.get("asset_type") == "native": tx_to = str(tx.get("to") or "").lower() tx_value = _hex_to_int(tx.get("value") or "0x0") if tx_to != wallet_to: raise RuntimeError("transaction destination does not match payment wallet") if tx_value != expected_units: raise RuntimeError("transaction value does not match frozen quote amount") return True tx_to = str(tx.get("to") or "").lower() contract = str(option.get("token_contract") or "").lower() if tx_to != contract: raise RuntimeError("token contract does not match expected asset contract") parsed = parse_erc20_transfer_input(tx.get("input") or "") if not parsed: raise RuntimeError("transaction input is not a supported ERC20 transfer") if str(parsed["to"]).lower() != wallet_to: raise RuntimeError("token transfer recipient does not match payment wallet") if int(parsed["amount"]) != expected_units: raise RuntimeError("token transfer amount does not match frozen quote amount") return True def get_processing_crypto_option(payment_row): currency = str(payment_row.get("payment_currency") or "").upper() amount_text = str(payment_row.get("payment_amount") or "0") wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS mapping = { "USDC": { "symbol": "USDC", "chain": "arbitrum", "wallet_address": wallet_address, "asset_type": "token", "decimals": 6, "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "display_amount": amount_text, }, "ETH": { "symbol": "ETH", "chain": "ethereum", "wallet_address": wallet_address, "asset_type": "native", "decimals": 18, "token_contract": None, "display_amount": amount_text, }, "ETHO": { "symbol": "ETHO", "chain": "etho", "wallet_address": wallet_address, "asset_type": "native", "decimals": 18, "token_contract": None, "display_amount": amount_text, }, "ETI": { "symbol": "ETI", "chain": "etica", "wallet_address": wallet_address, "asset_type": "token", "decimals": 18, "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", "display_amount": amount_text, }, } return mapping.get(currency) def reconcile_pending_crypto_payment(payment_row): tx_hash = str(payment_row.get("txid") or "").strip() if not tx_hash or not tx_hash.startswith("0x"): return {"state": "no_tx_hash"} option = get_processing_crypto_option(payment_row) if not option: return {"state": "unsupported_currency"} created_dt = payment_row.get("updated_at") or payment_row.get("created_at") if created_dt and created_dt.tzinfo is None: created_dt = created_dt.replace(tzinfo=timezone.utc) if not created_dt: created_dt = datetime.now(timezone.utc) age_seconds = max(0, int((datetime.now(timezone.utc) - created_dt).total_seconds())) rpc_urls = get_rpc_urls_for_chain(option.get("chain")) if not rpc_urls: return {"state": "no_rpc"} tx_hit = None receipt_hit = None tx_rpc = None receipt_rpc = None for rpc_url in rpc_urls: try: tx_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) tx_obj = tx_resp.get("result") if tx_obj: verify_expected_tx_for_payment(option, tx_obj) tx_hit = tx_obj tx_rpc = tx_resp.get("rpc_url") break except Exception: continue if tx_hit: for rpc_url in rpc_urls: try: receipt_resp = rpc_call_any([rpc_url], "eth_getTransactionReceipt", [tx_hash]) receipt_obj = receipt_resp.get("result") if receipt_obj: receipt_hit = receipt_obj receipt_rpc = receipt_resp.get("rpc_url") break except Exception: continue balance_info = fetch_rpc_balance(option.get("chain"), option.get("wallet_address")) conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, invoice_id, notes, payment_status FROM payments WHERE id = %s LIMIT 1 """, (payment_row["id"],)) live_payment = cursor.fetchone() if not live_payment: conn.close() return {"state": "payment_missing"} notes = live_payment.get("notes") or "" if tx_hit and receipt_hit: receipt_status = _hex_to_int(receipt_hit.get("status") or "0x0") confirmations = 0 block_number = receipt_hit.get("blockNumber") if block_number: try: bn = _hex_to_int(block_number) latest_resp = rpc_call_any(rpc_urls, "eth_blockNumber", []) latest_bn = _hex_to_int((latest_resp or {}).get("result") or "0x0") if latest_bn >= bn: confirmations = (latest_bn - bn) + 1 except Exception: confirmations = 1 if receipt_status == 1: notes = append_payment_note(notes, f"[reconcile] confirmed tx {tx_hash} via {receipt_rpc or tx_rpc or 'rpc'}") if balance_info: notes = append_payment_note(notes, f"[reconcile] wallet balance seen on {balance_info.get('rpc_url')}: {balance_info.get('balance_wei')} wei") update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'confirmed', confirmations = %s, confirmation_required = 1, received_at = COALESCE(received_at, UTC_TIMESTAMP()), notes = %s WHERE id = %s """, ( confirmations or 1, notes, payment_row["id"] )) conn.commit() conn.close() try: recalc_invoice_totals(payment_row["invoice_id"]) except Exception: pass return {"state": "confirmed", "confirmations": confirmations or 1} notes = append_payment_note(notes, f"[reconcile] receipt status failed for tx {tx_hash}") update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'failed', notes = %s WHERE id = %s """, (notes, payment_row["id"])) conn.commit() conn.close() try: recalc_invoice_totals(payment_row["invoice_id"]) except Exception: pass return {"state": "failed_receipt"} if age_seconds <= CRYPTO_PROCESSING_TIMEOUT_SECONDS: notes_line = f"[reconcile] waiting for tx/receipt age={age_seconds}s" if balance_info: notes_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" if notes_line not in notes: notes = append_payment_note(notes, notes_line) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET notes = %s WHERE id = %s """, (notes, payment_row["id"])) conn.commit() conn.close() return {"state": "processing", "age_seconds": age_seconds} fail_line = f"[reconcile] timeout after {age_seconds}s without confirmed receipt for tx {tx_hash}" if balance_info: fail_line += f" wallet_balance_wei={balance_info.get('balance_wei')}" notes = append_payment_note(notes, fail_line) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'failed', notes = %s WHERE id = %s """, (notes, payment_row["id"])) conn.commit() conn.close() try: recalc_invoice_totals(payment_row["invoice_id"]) except Exception: pass return {"state": "timeout", "age_seconds": age_seconds} def _to_base_units(amount_text, decimals): amount_dec = Decimal(str(amount_text)) scale = Decimal(10) ** int(decimals) return int((amount_dec * scale).quantize(Decimal("1"))) def _strip_0x(value): return str(value or "").lower().replace("0x", "") def parse_erc20_transfer_input(input_data): data = _strip_0x(input_data) if not data.startswith("a9059cbb"): return None if len(data) < 8 + 64 + 64: return None to_chunk = data[8:72] amount_chunk = data[72:136] to_addr = "0x" + to_chunk[-40:] amount_int = int(amount_chunk, 16) return { "to": to_addr, "amount": amount_int, } def verify_wallet_transaction(option, tx_hash): rpc_urls = get_rpc_urls_for_chain(option.get("chain")) if not rpc_urls: raise RuntimeError("No RPC configured for chain") seen_result = None last_not_found = False for rpc_url in rpc_urls: try: rpc_resp = rpc_call_any([rpc_url], "eth_getTransactionByHash", [tx_hash]) tx = rpc_resp.get("result") if not tx: last_not_found = True continue wallet_to = str(option.get("wallet_address") or "").lower() expected_units = _to_base_units(option.get("display_amount"), option.get("decimals") or 18) if option.get("asset_type") == "native": tx_to = str(tx.get("to") or "").lower() tx_value = int(tx.get("value") or "0x0", 16) if tx_to != wallet_to: raise RuntimeError("Transaction destination does not match payment wallet") if tx_value != expected_units: raise RuntimeError("Transaction value does not match frozen quote amount") else: tx_to = str(tx.get("to") or "").lower() contract = str(option.get("token_contract") or "").lower() if tx_to != contract: raise RuntimeError("Token contract does not match expected asset contract") parsed = parse_erc20_transfer_input(tx.get("input") or "") if not parsed: raise RuntimeError("Transaction input is not a supported ERC20 transfer") if str(parsed["to"]).lower() != wallet_to: raise RuntimeError("Token transfer recipient does not match payment wallet") if int(parsed["amount"]) != expected_units: raise RuntimeError("Token transfer amount does not match frozen quote amount") seen_result = { "rpc_url": rpc_url, "tx": tx, } break except Exception: continue if not seen_result: if last_not_found: raise RuntimeError("Transaction hash not found on any configured RPC") raise RuntimeError("Unable to verify transaction on configured RPC pool") return seen_result def get_processing_crypto_option(payment_row): currency = str(payment_row.get("payment_currency") or "").upper() amount_text = str(payment_row.get("payment_amount") or "0") wallet_address = payment_row.get("wallet_address") or CRYPTO_EVM_PAYMENT_ADDRESS mapping = { "USDC": { "symbol": "USDC", "chain": "arbitrum", "wallet_address": wallet_address, "asset_type": "token", "decimals": 6, "token_contract": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "display_amount": amount_text, }, "ETH": { "symbol": "ETH", "chain": "ethereum", "wallet_address": wallet_address, "asset_type": "native", "decimals": 18, "token_contract": None, "display_amount": amount_text, }, "ETHO": { "symbol": "ETHO", "chain": "etho", "wallet_address": wallet_address, "asset_type": "native", "decimals": 18, "token_contract": None, "display_amount": amount_text, }, "ETI": { "symbol": "ETI", "chain": "etica", "wallet_address": wallet_address, "asset_type": "token", "decimals": 18, "token_contract": "0x34c61EA91bAcdA647269d4e310A86b875c09946f", "display_amount": amount_text, }, } return mapping.get(currency) def append_payment_note(existing_notes, extra_line): base = (existing_notes or "").rstrip() if not base: return extra_line.strip() return base + "\n" + extra_line.strip() def mark_crypto_payment_failed(payment_id, reason): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT invoice_id, notes FROM payments WHERE id = %s LIMIT 1 """, (payment_id,)) row = cursor.fetchone() if not row: conn.close() return new_notes = append_payment_note( row.get("notes"), f"[crypto watcher] failed: {reason}" ) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'failed', notes = %s WHERE id = %s """, (new_notes, payment_id)) conn.commit() conn.close() try: recalc_invoice_totals(row["invoice_id"]) except Exception: pass def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT 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] confirmed via {rpc_url}" ) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'confirmed', confirmations = COALESCE(confirmations, 1), confirmation_required = COALESCE(confirmation_required, 1), received_at = COALESCE(received_at, UTC_TIMESTAMP()), notes = %s WHERE id = %s """, (new_notes, payment_id)) conn.commit() conn.close() try: recalc_invoice_totals(invoice_id) except Exception: pass 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(): global CRYPTO_WATCHER_STARTED if CRYPTO_WATCHER_STARTED: return t = threading.Thread( target=crypto_payment_watcher_loop, name="crypto-payment-watcher", daemon=True, ) t.start() CRYPTO_WATCHER_STARTED = True def square_amount_to_cents(value): return int((to_decimal(value) * 100).quantize(Decimal("1"))) def create_square_payment_link_for_invoice(invoice_row, buyer_email=""): if not SQUARE_ACCESS_TOKEN: raise RuntimeError("Square access token is not configured") invoice_number = invoice_row.get("invoice_number") or f"INV-{invoice_row.get('id')}" currency_code = invoice_row.get("currency_code") or "CAD" amount_cents = square_amount_to_cents(invoice_row.get("total_amount") or "0") location_id = "1TSPHT78106WX" payload = { "idempotency_key": str(uuid.uuid4()), "description": f"OTB Billing invoice {invoice_number}", "quick_pay": { "name": f"Invoice {invoice_number}", "price_money": { "amount": amount_cents, "currency": currency_code }, "location_id": location_id }, "payment_note": f"Invoice {invoice_number}", "checkout_options": { "redirect_url": "https://portal.outsidethebox.top/portal" } } if buyer_email: payload["pre_populated_data"] = { "buyer_email": buyer_email } req = urllib.request.Request( f"{SQUARE_API_BASE}/v2/online-checkout/payment-links", data=json.dumps(payload).encode("utf-8"), headers={ "Authorization": f"Bearer {SQUARE_ACCESS_TOKEN}", "Square-Version": SQUARE_API_VERSION, "Content-Type": "application/json" }, method="POST" ) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") raise RuntimeError(f"Square payment link creation failed: {e.code} {body}") payment_link = (data or {}).get("payment_link") or {} url = payment_link.get("url") if not url: raise RuntimeError(f"Square payment link response missing URL: {data}") return url def square_signature_is_valid(signature_header, raw_body, notification_url): if not SQUARE_WEBHOOK_SIGNATURE_KEY or not signature_header: return False message = notification_url.encode("utf-8") + raw_body digest = hmac.new( SQUARE_WEBHOOK_SIGNATURE_KEY.encode("utf-8"), message, hashlib.sha256 ).digest() computed_signature = base64.b64encode(digest).decode("utf-8") return hmac.compare_digest(computed_signature, signature_header) def append_square_webhook_log(entry): try: log_path = Path(SQUARE_WEBHOOK_LOG) log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") except Exception: pass def generate_portal_access_code(): alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" groups = [] for _ in range(3): groups.append("".join(secrets.choice(alphabet) for _ in range(4))) return "-".join(groups) def refresh_overdue_invoices(): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" UPDATE invoices SET status = 'overdue' WHERE due_at IS NOT NULL AND due_at < UTC_TIMESTAMP() AND status IN ('pending', 'partial') """) conn.commit() conn.close() def recalc_invoice_totals(invoice_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, total_amount, due_at, status FROM invoices WHERE id = %s """, (invoice_id,)) invoice = cursor.fetchone() if not invoice: conn.close() return cursor.execute(""" SELECT COALESCE(SUM(payment_amount), 0) AS total_paid FROM payments WHERE invoice_id = %s AND payment_status = 'confirmed' """, (invoice_id,)) row = cursor.fetchone() total_paid = to_decimal(row["total_paid"]) total_amount = to_decimal(invoice["total_amount"]) if invoice["status"] == "cancelled": update_cursor = conn.cursor() update_cursor.execute(""" UPDATE invoices SET amount_paid = %s, paid_at = NULL WHERE id = %s """, ( str(total_paid), invoice_id )) conn.commit() conn.close() return if total_paid >= total_amount and total_amount > 0: new_status = "paid" paid_at_value = "UTC_TIMESTAMP()" elif total_paid > 0: new_status = "partial" paid_at_value = "NULL" else: if invoice["due_at"] and invoice["due_at"] < datetime.utcnow(): new_status = "overdue" else: new_status = "pending" paid_at_value = "NULL" update_cursor = conn.cursor() update_cursor.execute(f""" UPDATE invoices SET amount_paid = %s, status = %s, paid_at = {paid_at_value} WHERE id = %s """, ( str(total_paid), new_status, invoice_id )) conn.commit() conn.close() def get_client_credit_balance(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT COALESCE(SUM(amount), 0) AS balance FROM credit_ledger WHERE client_id = %s """, (client_id,)) row = cursor.fetchone() conn.close() return to_decimal(row["balance"]) def generate_invoice_number(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT invoice_number FROM invoices WHERE invoice_number IS NOT NULL AND invoice_number LIKE 'INV-%' ORDER BY id DESC LIMIT 1 """) row = cursor.fetchone() conn.close() if not row or not row.get("invoice_number"): return "INV-0001" invoice_number = str(row["invoice_number"]).strip() try: number = int(invoice_number.split("-")[1]) except (IndexError, ValueError): return "INV-0001" return f"INV-{number + 1:04d}" def ensure_subscriptions_table(): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS subscriptions ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, client_id INT UNSIGNED NOT NULL, service_id INT UNSIGNED NULL, subscription_name VARCHAR(255) NOT NULL, billing_interval ENUM('monthly','quarterly','yearly') NOT NULL DEFAULT 'monthly', price DECIMAL(18,8) NOT NULL DEFAULT 0.00000000, currency_code VARCHAR(16) NOT NULL DEFAULT 'CAD', start_date DATE NOT NULL, next_invoice_date DATE NOT NULL, status ENUM('active','paused','cancelled') NOT NULL DEFAULT 'active', notes TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_subscriptions_client_id (client_id), KEY idx_subscriptions_service_id (service_id), KEY idx_subscriptions_status (status), KEY idx_subscriptions_next_invoice_date (next_invoice_date) ) """) conn.commit() conn.close() def get_next_subscription_date(current_date, billing_interval): if isinstance(current_date, str): current_date = datetime.strptime(current_date, "%Y-%m-%d").date() if billing_interval == "yearly": return current_date + relativedelta(years=1) if billing_interval == "quarterly": return current_date + relativedelta(months=3) return current_date + relativedelta(months=1) def generate_due_subscription_invoices(run_date=None): ensure_subscriptions_table() today = run_date or date.today() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT s.*, c.client_code, c.company_name, srv.service_code, srv.service_name FROM subscriptions s JOIN clients c ON s.client_id = c.id LEFT JOIN services srv ON s.service_id = srv.id WHERE s.status = 'active' AND s.next_invoice_date <= %s ORDER BY s.next_invoice_date ASC, s.id ASC """, (today,)) due_subscriptions = cursor.fetchall() created_count = 0 created_invoice_numbers = [] for sub in due_subscriptions: invoice_number = generate_invoice_number() due_dt = datetime.combine(today + timedelta(days=14), datetime.min.time()) note_parts = [f"Recurring subscription: {sub['subscription_name']}"] if sub.get("service_code"): note_parts.append(f"Service: {sub['service_code']}") if sub.get("service_name"): note_parts.append(f"({sub['service_name']})") if sub.get("notes"): note_parts.append(f"Notes: {sub['notes']}") note_text = " ".join(note_parts) insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO invoices ( client_id, service_id, invoice_number, currency_code, total_amount, subtotal_amount, tax_amount, issued_at, due_at, status, notes ) VALUES (%s, %s, %s, %s, %s, %s, 0, UTC_TIMESTAMP(), %s, 'pending', %s) """, ( sub["client_id"], sub["service_id"], invoice_number, sub["currency_code"], str(sub["price"]), str(sub["price"]), due_dt, note_text, )) next_date = get_next_subscription_date(sub["next_invoice_date"], sub["billing_interval"]) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE subscriptions SET next_invoice_date = %s WHERE id = %s """, (next_date, sub["id"])) created_count += 1 created_invoice_numbers.append(invoice_number) conn.commit() conn.close() return { "created_count": created_count, "invoice_numbers": created_invoice_numbers, "run_date": str(today), } APP_SETTINGS_DEFAULTS = { "business_name": "OTB Billing", "business_tagline": "By a contractor, for contractors", "business_logo_url": "", "business_email": "", "business_phone": "", "business_address": "", "business_website": "", "tax_label": "HST", "tax_rate": "13.00", "tax_number": "", "business_number": "", "default_currency": "CAD", "report_frequency": "monthly", "invoice_footer": "", "payment_terms": "", "local_country": "Canada", "apply_local_tax_only": "1", "smtp_host": "", "smtp_port": "587", "smtp_user": "", "smtp_pass": "", "smtp_from_email": "", "smtp_from_name": "", "smtp_use_tls": "1", "smtp_use_ssl": "0", "report_delivery_email": "", } def ensure_app_settings_table(): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS app_settings ( setting_key VARCHAR(100) NOT NULL PRIMARY KEY, setting_value TEXT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) """) conn.commit() conn.close() def get_app_settings(): ensure_app_settings_table() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT setting_key, setting_value FROM app_settings """) rows = cursor.fetchall() conn.close() settings = dict(APP_SETTINGS_DEFAULTS) for row in rows: settings[row["setting_key"]] = row["setting_value"] if row["setting_value"] is not None else "" return settings def save_app_settings(form_data): ensure_app_settings_table() conn = get_db_connection() cursor = conn.cursor() for key in APP_SETTINGS_DEFAULTS.keys(): if key in {"apply_local_tax_only", "smtp_use_tls", "smtp_use_ssl"}: value = "1" if form_data.get(key) else "0" else: value = (form_data.get(key) or "").strip() cursor.execute(""" INSERT INTO app_settings (setting_key, setting_value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) """, (key, value)) conn.commit() conn.close() @app.template_filter("localtime") def localtime_filter(value): return fmt_local(value) @app.template_filter("money") def money_filter(value, currency_code="CAD"): return fmt_money(value, currency_code) def get_report_period_bounds(frequency): now_local = datetime.now(LOCAL_TZ) if frequency == "yearly": start_local = now_local.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) label = f"{now_local.year}" elif frequency == "quarterly": quarter = ((now_local.month - 1) // 3) + 1 start_month = (quarter - 1) * 3 + 1 start_local = now_local.replace(month=start_month, day=1, hour=0, minute=0, second=0, microsecond=0) label = f"Q{quarter} {now_local.year}" else: start_local = now_local.replace(day=1, hour=0, minute=0, second=0, microsecond=0) label = now_local.strftime("%B %Y") start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None) end_utc = now_local.astimezone(timezone.utc).replace(tzinfo=None) return start_utc, end_utc, label def build_accounting_package_bytes(): import json import zipfile from io import BytesIO report = get_revenue_report_data() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.id, i.invoice_number, i.status, i.total_amount, i.amount_paid, i.created_at, c.company_name, c.contact_name FROM invoices i JOIN clients c ON i.client_id = c.id ORDER BY i.created_at DESC """) invoices = cursor.fetchall() conn.close() payload = { "report": report, "invoices": invoices } json_bytes = json.dumps(payload, indent=2, default=str).encode() zip_buffer = BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("revenue_report.json", json.dumps(report, indent=2)) z.writestr("invoices.json", json.dumps(invoices, indent=2, default=str)) zip_buffer.seek(0) filename = f"accounting_package_{report.get('period_label','report')}.zip" return zip_buffer.read(), filename def get_revenue_report_data(): settings = get_app_settings() frequency = (settings.get("report_frequency") or "monthly").strip().lower() if frequency not in {"monthly", "quarterly", "yearly"}: frequency = "monthly" start_utc, end_utc, label = get_report_period_bounds(frequency) conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT COALESCE(SUM(cad_value_at_payment), 0) AS collected FROM payments WHERE payment_status = 'confirmed' AND received_at >= %s AND received_at <= %s """, (start_utc, end_utc)) collected_row = cursor.fetchone() cursor.execute(""" SELECT COUNT(*) AS invoice_count, COALESCE(SUM(total_amount), 0) AS invoiced FROM invoices WHERE issued_at >= %s AND issued_at <= %s """, (start_utc, end_utc)) invoiced_row = cursor.fetchone() cursor.execute(""" SELECT COUNT(*) AS overdue_count, COALESCE(SUM(total_amount - amount_paid), 0) AS overdue_balance FROM invoices WHERE status = 'overdue' """) overdue_row = cursor.fetchone() cursor.execute(""" SELECT COUNT(*) AS outstanding_count, COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance FROM invoices WHERE status IN ('pending', 'partial', 'overdue') """) outstanding_row = cursor.fetchone() conn.close() return { "frequency": frequency, "period_label": label, "period_start": start_utc.isoformat(sep=" "), "period_end": end_utc.isoformat(sep=" "), "collected_cad": str(to_decimal(collected_row["collected"])), "invoice_count": int(invoiced_row["invoice_count"] or 0), "invoiced_total": str(to_decimal(invoiced_row["invoiced"])), "overdue_count": int(overdue_row["overdue_count"] or 0), "overdue_balance": str(to_decimal(overdue_row["overdue_balance"])), "outstanding_count": int(outstanding_row["outstanding_count"] or 0), "outstanding_balance": str(to_decimal(outstanding_row["outstanding_balance"])), } def ensure_email_log_table(): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS email_log ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, email_type VARCHAR(50) NOT NULL, invoice_id INT UNSIGNED NULL, recipient_email VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, error_message TEXT NULL, sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_email_log_invoice_id (invoice_id), KEY idx_email_log_type (email_type), KEY idx_email_log_sent_at (sent_at) ) """) conn.commit() conn.close() def log_email_event(email_type, recipient_email, subject, status, invoice_id=None, error_message=None): ensure_email_log_table() conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" INSERT INTO email_log (email_type, invoice_id, recipient_email, subject, status, error_message) VALUES (%s, %s, %s, %s, %s, %s) """, ( email_type, invoice_id, recipient_email, subject, status, error_message )) conn.commit() conn.close() def send_configured_email(to_email, subject, body, attachments=None, email_type="system_email", invoice_id=None): settings = get_app_settings() smtp_host = (settings.get("smtp_host") or "").strip() smtp_port = int((settings.get("smtp_port") or "587").strip() or "587") smtp_user = (settings.get("smtp_user") or "").strip() smtp_pass = (settings.get("smtp_pass") or "").strip() from_email = (settings.get("smtp_from_email") or settings.get("business_email") or "").strip() from_name = (settings.get("smtp_from_name") or settings.get("business_name") or "").strip() use_tls = (settings.get("smtp_use_tls") or "0") == "1" use_ssl = (settings.get("smtp_use_ssl") or "0") == "1" if not smtp_host: raise ValueError("SMTP host is not configured.") if not from_email: raise ValueError("From email is not configured.") if not to_email: raise ValueError("Recipient email is missing.") msg = EmailMessage() msg["Subject"] = subject msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email msg["To"] = to_email msg.set_content(body) for attachment in attachments or []: filename = attachment["filename"] mime_type = attachment["mime_type"] data = attachment["data"] maintype, subtype = mime_type.split("/", 1) msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) try: if use_ssl: with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30) as server: if smtp_user: server.login(smtp_user, smtp_pass) server.send_message(msg) else: with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server: server.ehlo() if use_tls: server.starttls() server.ehlo() if smtp_user: server.login(smtp_user, smtp_pass) server.send_message(msg) log_email_event(email_type, to_email, subject, "sent", invoice_id=invoice_id, error_message=None) except Exception as e: log_email_event(email_type, to_email, subject, "failed", invoice_id=invoice_id, error_message=str(e)) raise @app.route("/settings", methods=["GET", "POST"]) def settings(): ensure_app_settings_table() if request.method == "POST": save_app_settings(request.form) return redirect("/settings") settings = get_app_settings() return render_template("settings.html", settings=settings) @app.route("/reports/accounting-package.zip") def accounting_package_zip(): package_bytes, filename = build_accounting_package_bytes() return send_file( BytesIO(package_bytes), mimetype="application/zip", as_attachment=True, download_name=filename ) @app.route("/reports/revenue") def revenue_report(): report = get_revenue_report_data() return render_template("reports/revenue.html", report=report) @app.route("/reports/revenue.json") def revenue_report_json(): report = get_revenue_report_data() return jsonify(report) @app.route("/reports/revenue/print") def revenue_report_print(): report = get_revenue_report_data() return render_template("reports/revenue_print.html", report=report) @app.route("/invoices/email/", methods=["POST"]) def email_invoice(invoice_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, c.client_code, c.company_name, c.contact_name, c.email, c.phone FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.id = %s """, (invoice_id,)) invoice = cursor.fetchone() conn.close() if not invoice: return "Invoice not found", 404 recipient = (invoice.get("email") or "").strip() if not recipient: return "Client email is missing for this invoice.", 400 settings = get_app_settings() with app.test_client() as client: pdf_response = client.get(f"/invoices/pdf/{invoice_id}") if pdf_response.status_code != 200: return "Could not generate invoice PDF for email.", 500 pdf_bytes = pdf_response.data remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) subject = f"Invoice {invoice['invoice_number']} from {settings.get('business_name') or 'OTB Billing'}" body = ( f"Hello {invoice.get('contact_name') or invoice.get('company_name') or ''},\n\n" f"Please find attached invoice {invoice['invoice_number']}.\n" f"Total: {to_decimal(invoice.get('total_amount')):.2f} {invoice.get('currency_code', 'CAD')}\n" f"Remaining: {remaining:.2f} {invoice.get('currency_code', 'CAD')}\n" f"Due: {fmt_local(invoice.get('due_at'))}\n\n" f"Thank you,\n" f"{settings.get('business_name') or 'OTB Billing'}" ) try: send_configured_email( recipient, subject, body, email_type="invoice", invoice_id=invoice_id, attachments=[{ "filename": f"{invoice['invoice_number']}.pdf", "mime_type": "application/pdf", "data": pdf_bytes, }] ) return redirect(f"/invoices/view/{invoice_id}?email_sent=1") except Exception: return redirect(f"/invoices/view/{invoice_id}?email_failed=1") @app.route("/reports/revenue/email", methods=["POST"]) def email_revenue_report_json(): settings = get_app_settings() recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip() if not recipient: return "Report delivery email is not configured.", 400 with app.test_client() as client: json_response = client.get("/reports/revenue.json") if json_response.status_code != 200: return "Could not generate revenue report JSON.", 500 report = get_revenue_report_data() subject = f"Revenue Report {report.get('period_label', '')} from {settings.get('business_name') or 'OTB Billing'}" body = ( f"Attached is the revenue report JSON for {report.get('period_label', '')}.\n\n" f"Frequency: {report.get('frequency', '')}\n" f"Collected CAD: {report.get('collected_cad', '')}\n" f"Invoices Issued: {report.get('invoice_count', '')}\n" ) try: send_configured_email( recipient, subject, body, email_type="revenue_report", attachments=[{ "filename": "revenue_report.json", "mime_type": "application/json", "data": json_response.data, }] ) return redirect("/reports/revenue?email_sent=1") except Exception: return redirect("/reports/revenue?email_failed=1") @app.route("/reports/accounting-package/email", methods=["POST"]) def email_accounting_package(): settings = get_app_settings() recipient = (settings.get("report_delivery_email") or settings.get("business_email") or "").strip() if not recipient: return "Report delivery email is not configured.", 400 with app.test_client() as client: zip_response = client.get("/reports/accounting-package.zip") if zip_response.status_code != 200: return "Could not generate accounting package ZIP.", 500 subject = f"Accounting Package from {settings.get('business_name') or 'OTB Billing'}" body = "Attached is the latest accounting package export." try: send_configured_email( recipient, subject, body, email_type="accounting_package", attachments=[{ "filename": "accounting_package.zip", "mime_type": "application/zip", "data": zip_response.data, }] ) return redirect("/?pkg_email=1") except Exception: return redirect("/?pkg_email_failed=1") @app.route("/subscriptions") def subscriptions(): ensure_subscriptions_table() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT s.*, c.client_code, c.company_name, srv.service_code, srv.service_name FROM subscriptions s JOIN clients c ON s.client_id = c.id LEFT JOIN services srv ON s.service_id = srv.id ORDER BY s.id DESC """) subscriptions = cursor.fetchall() conn.close() return render_template("subscriptions/list.html", subscriptions=subscriptions) @app.route("/subscriptions/new", methods=["GET", "POST"]) def new_subscription(): ensure_subscriptions_table() conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form.get("client_id", "").strip() service_id = request.form.get("service_id", "").strip() subscription_name = request.form.get("subscription_name", "").strip() billing_interval = request.form.get("billing_interval", "").strip() price = request.form.get("price", "").strip() currency_code = request.form.get("currency_code", "").strip() start_date_value = request.form.get("start_date", "").strip() next_invoice_date = request.form.get("next_invoice_date", "").strip() status = request.form.get("status", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not subscription_name: errors.append("Subscription name is required.") if billing_interval not in {"monthly", "quarterly", "yearly"}: errors.append("Billing interval is required.") if not price: errors.append("Price is required.") if not currency_code: errors.append("Currency is required.") if not start_date_value: errors.append("Start date is required.") if not next_invoice_date: errors.append("Next invoice date is required.") if status not in {"active", "paused", "cancelled"}: errors.append("Status is required.") if not errors: try: price_value = Decimal(str(price)) if price_value <= Decimal("0"): errors.append("Price must be greater than zero.") except Exception: errors.append("Price must be a valid number.") if errors: cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") clients = cursor.fetchall() cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") services = cursor.fetchall() conn.close() return render_template( "subscriptions/new.html", clients=clients, services=services, errors=errors, form_data={ "client_id": client_id, "service_id": service_id, "subscription_name": subscription_name, "billing_interval": billing_interval, "price": price, "currency_code": currency_code, "start_date": start_date_value, "next_invoice_date": next_invoice_date, "status": status, "notes": notes, }, ) insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO subscriptions ( client_id, service_id, subscription_name, billing_interval, price, currency_code, start_date, next_invoice_date, status, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( client_id, service_id or None, subscription_name, billing_interval, str(price_value), currency_code, start_date_value, next_invoice_date, status, notes or None, )) conn.commit() conn.close() return redirect("/subscriptions") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") clients = cursor.fetchall() cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") services = cursor.fetchall() conn.close() today_str = date.today().isoformat() return render_template( "subscriptions/new.html", clients=clients, services=services, errors=[], form_data={ "billing_interval": "monthly", "currency_code": "CAD", "start_date": today_str, "next_invoice_date": today_str, "status": "active", }, ) @app.route("/subscriptions/run", methods=["POST"]) def run_subscriptions_now(): result = generate_due_subscription_invoices() return redirect(f"/subscriptions?run_count={result['created_count']}") @app.route("/reports/aging") def report_aging(): refresh_overdue_invoices() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT c.id AS client_id, c.client_code, c.company_name, i.invoice_number, i.due_at, i.total_amount, i.amount_paid, (i.total_amount - i.amount_paid) AS remaining FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.status IN ('pending', 'partial', 'overdue') AND (i.total_amount - i.amount_paid) > 0 ORDER BY c.company_name, i.due_at """) rows = cursor.fetchall() conn.close() today = datetime.utcnow().date() grouped = {} totals = { "current": Decimal("0"), "d30": Decimal("0"), "d60": Decimal("0"), "d90": Decimal("0"), "d90p": Decimal("0"), "total": Decimal("0"), } for row in rows: client_id = row["client_id"] client_label = f"{row['client_code']} - {row['company_name']}" if client_id not in grouped: grouped[client_id] = { "client": client_label, "current": Decimal("0"), "d30": Decimal("0"), "d60": Decimal("0"), "d90": Decimal("0"), "d90p": Decimal("0"), "total": Decimal("0"), } remaining = to_decimal(row["remaining"]) if row["due_at"]: due_date = row["due_at"].date() age_days = (today - due_date).days else: age_days = 0 if age_days <= 0: bucket = "current" elif age_days <= 30: bucket = "d30" elif age_days <= 60: bucket = "d60" elif age_days <= 90: bucket = "d90" else: bucket = "d90p" grouped[client_id][bucket] += remaining grouped[client_id]["total"] += remaining totals[bucket] += remaining totals["total"] += remaining aging_rows = list(grouped.values()) return render_template( "reports/aging.html", aging_rows=aging_rows, totals=totals ) @app.route("/") def index(): refresh_overdue_invoices() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute("SELECT COUNT(*) AS total_clients FROM clients") total_clients = cursor.fetchone()["total_clients"] cursor.execute("SELECT COUNT(*) AS active_services FROM services WHERE status = 'active'") active_services = cursor.fetchone()["active_services"] cursor.execute(""" SELECT COUNT(*) AS outstanding_invoices FROM invoices WHERE status IN ('pending', 'partial', 'overdue') AND (total_amount - amount_paid) > 0 """) outstanding_invoices = cursor.fetchone()["outstanding_invoices"] cursor.execute(""" SELECT COALESCE(SUM(cad_value_at_payment), 0) AS revenue_received FROM payments WHERE payment_status = 'confirmed' """) revenue_received = to_decimal(cursor.fetchone()["revenue_received"]) cursor.execute(""" SELECT COALESCE(SUM(total_amount - amount_paid), 0) AS outstanding_balance FROM invoices WHERE status IN ('pending', 'partial', 'overdue') AND (total_amount - amount_paid) > 0 """) outstanding_balance = to_decimal(cursor.fetchone()["outstanding_balance"]) conn.close() app_settings = get_app_settings() return render_template( "dashboard.html", total_clients=total_clients, active_services=active_services, outstanding_invoices=outstanding_invoices, outstanding_balance=outstanding_balance, revenue_received=revenue_received, app_settings=app_settings, ) @app.route("/clients") def clients(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT c.*, COALESCE(( SELECT SUM(i.total_amount - i.amount_paid) FROM invoices i WHERE i.client_id = c.id AND i.status IN ('pending', 'partial', 'overdue') AND (i.total_amount - i.amount_paid) > 0 ), 0) AS outstanding_balance FROM clients c ORDER BY c.company_name """) clients = cursor.fetchall() conn.close() return render_template("clients/list.html", clients=clients) @app.route("/clients/new", methods=["GET", "POST"]) def new_client(): if request.method == "POST": company_name = request.form["company_name"] contact_name = request.form["contact_name"] email = request.form["email"] phone = request.form["phone"] conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute("SELECT MAX(id) AS last_id FROM clients") result = cursor.fetchone() last_number = result["last_id"] if result["last_id"] else 0 client_code = generate_client_code(company_name, last_number) insert_cursor = conn.cursor() insert_cursor.execute( """ INSERT INTO clients (client_code, company_name, contact_name, email, phone) VALUES (%s, %s, %s, %s, %s) """, (client_code, company_name, contact_name, email, phone) ) conn.commit() conn.close() return redirect("/clients") return render_template("clients/new.html") @app.route("/clients/edit/", methods=["GET", "POST"]) def edit_client(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": company_name = request.form.get("company_name", "").strip() contact_name = request.form.get("contact_name", "").strip() email = request.form.get("email", "").strip() phone = request.form.get("phone", "").strip() status = request.form.get("status", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not company_name: errors.append("Company name is required.") if not status: errors.append("Status is required.") if errors: cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) client = cursor.fetchone() client["credit_balance"] = get_client_credit_balance(client_id) conn.close() return render_template("clients/edit.html", client=client, errors=errors) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE clients SET company_name = %s, contact_name = %s, email = %s, phone = %s, status = %s, notes = %s WHERE id = %s """, ( company_name, contact_name or None, email or None, phone or None, status, notes or None, client_id )) conn.commit() conn.close() return redirect("/clients") cursor.execute("SELECT * FROM clients WHERE id = %s", (client_id,)) client = cursor.fetchone() conn.close() if not client: return "Client not found", 404 client["credit_balance"] = get_client_credit_balance(client_id) return render_template("clients/edit.html", client=client, errors=[]) @app.route("/credits/") def client_credits(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, client_code, company_name FROM clients WHERE id = %s """, (client_id,)) client = cursor.fetchone() if not client: conn.close() return "Client not found", 404 cursor.execute(""" SELECT * FROM credit_ledger WHERE client_id = %s ORDER BY id DESC """, (client_id,)) entries = cursor.fetchall() conn.close() balance = get_client_credit_balance(client_id) return render_template( "credits/list.html", client=client, entries=entries, balance=balance, ) @app.route("/credits/add/", methods=["GET", "POST"]) def add_credit(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, client_code, company_name FROM clients WHERE id = %s """, (client_id,)) client = cursor.fetchone() if not client: conn.close() return "Client not found", 404 if request.method == "POST": entry_type = request.form.get("entry_type", "").strip() amount = request.form.get("amount", "").strip() currency_code = request.form.get("currency_code", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not entry_type: errors.append("Entry type is required.") if not amount: errors.append("Amount is required.") if not currency_code: errors.append("Currency code is required.") if not errors: try: amount_value = Decimal(str(amount)) if amount_value == 0: errors.append("Amount cannot be zero.") except Exception: errors.append("Amount must be a valid number.") if errors: conn.close() return render_template("credits/add.html", client=client, errors=errors) insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO credit_ledger ( client_id, entry_type, amount, currency_code, notes ) VALUES (%s, %s, %s, %s, %s) """, ( client_id, entry_type, amount, currency_code, notes or None )) conn.commit() conn.close() return redirect(f"/credits/{client_id}") conn.close() return render_template("credits/add.html", client=client, errors=[]) @app.route("/services") def services(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT s.*, c.client_code, c.company_name FROM services s JOIN clients c ON s.client_id = c.id ORDER BY s.id DESC """) services = cursor.fetchall() conn.close() return render_template("services/list.html", services=services) @app.route("/services/new", methods=["GET", "POST"]) def new_service(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form["client_id"] service_name = request.form["service_name"] service_type = request.form["service_type"] billing_cycle = request.form["billing_cycle"] currency_code = request.form["currency_code"] recurring_amount = request.form["recurring_amount"] status = request.form["status"] start_date = request.form["start_date"] or None description = request.form["description"] cursor.execute("SELECT MAX(id) AS last_id FROM services") result = cursor.fetchone() last_number = result["last_id"] if result["last_id"] else 0 service_code = generate_service_code(service_name, last_number) insert_cursor = conn.cursor() insert_cursor.execute( """ INSERT INTO services ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( client_id, service_code, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date, description ) ) conn.commit() conn.close() return redirect("/services") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() return render_template("services/new.html", clients=clients) @app.route("/services/edit/", methods=["GET", "POST"]) def edit_service(service_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form.get("client_id", "").strip() service_name = request.form.get("service_name", "").strip() service_type = request.form.get("service_type", "").strip() billing_cycle = request.form.get("billing_cycle", "").strip() currency_code = request.form.get("currency_code", "").strip() recurring_amount = request.form.get("recurring_amount", "").strip() status = request.form.get("status", "").strip() start_date = request.form.get("start_date", "").strip() description = request.form.get("description", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not service_name: errors.append("Service name is required.") if not service_type: errors.append("Service type is required.") if not billing_cycle: errors.append("Billing cycle is required.") if not currency_code: errors.append("Currency code is required.") if not recurring_amount: errors.append("Recurring amount is required.") if not status: errors.append("Status is required.") if not errors: try: recurring_amount_value = float(recurring_amount) if recurring_amount_value < 0: errors.append("Recurring amount cannot be negative.") except ValueError: errors.append("Recurring amount must be a valid number.") if errors: cursor.execute(""" SELECT s.*, c.company_name FROM services s LEFT JOIN clients c ON s.client_id = c.id WHERE s.id = %s """, (service_id,)) service = cursor.fetchone() cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() return render_template("services/edit.html", service=service, clients=clients, errors=errors) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE services SET client_id = %s, service_name = %s, service_type = %s, billing_cycle = %s, status = %s, currency_code = %s, recurring_amount = %s, start_date = %s, description = %s WHERE id = %s """, ( client_id, service_name, service_type, billing_cycle, status, currency_code, recurring_amount, start_date or None, description or None, service_id )) conn.commit() conn.close() return redirect("/services") cursor.execute(""" SELECT s.*, c.company_name FROM services s LEFT JOIN clients c ON s.client_id = c.id WHERE s.id = %s """, (service_id,)) service = cursor.fetchone() cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC") clients = cursor.fetchall() conn.close() if not service: return "Service not found", 404 return render_template("services/edit.html", service=service, clients=clients, errors=[]) @app.route("/invoices/export.csv") def export_invoices_csv(): start_date = (request.args.get("start_date") or "").strip() end_date = (request.args.get("end_date") or "").strip() status = (request.args.get("status") or "").strip() client_id = (request.args.get("client_id") or "").strip() limit_count = (request.args.get("limit") or "").strip() conn = get_db_connection() cursor = conn.cursor(dictionary=True) query = """ SELECT i.id, i.invoice_number, i.client_id, c.client_code, c.company_name, i.service_id, i.currency_code, i.subtotal_amount, i.tax_amount, i.total_amount, i.amount_paid, i.status, i.issued_at, i.due_at, i.paid_at, i.notes, i.created_at, i.updated_at FROM invoices i JOIN clients c ON i.client_id = c.id WHERE 1=1 """ params = [] if start_date: query += " AND DATE(i.issued_at) >= %s" params.append(start_date) if end_date: query += " AND DATE(i.issued_at) <= %s" params.append(end_date) if status: query += " AND i.status = %s" params.append(status) if client_id: query += " AND i.client_id = %s" params.append(client_id) query += " ORDER BY i.id ASC" if limit_count: try: limit_int = int(limit_count) if limit_int > 0: query += " LIMIT %s" params.append(limit_int) except ValueError: pass cursor.execute(query, tuple(params)) rows = cursor.fetchall() conn.close() output = StringIO() writer = csv.writer(output) writer.writerow([ "id", "invoice_number", "client_id", "client_code", "company_name", "service_id", "currency_code", "subtotal_amount", "tax_amount", "total_amount", "amount_paid", "status", "issued_at", "due_at", "paid_at", "notes", "created_at", "updated_at", ]) for r in rows: writer.writerow([ r.get("id", ""), r.get("invoice_number", ""), r.get("client_id", ""), r.get("client_code", ""), r.get("company_name", ""), r.get("service_id", ""), r.get("currency_code", ""), r.get("subtotal_amount", ""), r.get("tax_amount", ""), r.get("total_amount", ""), r.get("amount_paid", ""), r.get("status", ""), r.get("issued_at", ""), r.get("due_at", ""), r.get("paid_at", ""), r.get("notes", ""), r.get("created_at", ""), r.get("updated_at", ""), ]) filename = "invoices" if start_date or end_date or status or client_id or limit_count: filename += "_filtered" filename += ".csv" response = make_response(output.getvalue()) response.headers["Content-Type"] = "text/csv; charset=utf-8" response.headers["Content-Disposition"] = f"attachment; filename={filename}" return response @app.route("/invoices/export-pdf.zip") def export_invoices_pdf_zip(): start_date = (request.args.get("start_date") or "").strip() end_date = (request.args.get("end_date") or "").strip() status = (request.args.get("status") or "").strip() client_id = (request.args.get("client_id") or "").strip() limit_count = (request.args.get("limit") or "").strip() conn = get_db_connection() cursor = conn.cursor(dictionary=True) query = """ SELECT i.*, c.client_code, c.company_name, c.contact_name, c.email, c.phone, s.service_code, s.service_name FROM invoices i JOIN clients c ON i.client_id = c.id LEFT JOIN services s ON i.service_id = s.id WHERE 1=1 """ params = [] if start_date: query += " AND DATE(i.issued_at) >= %s" params.append(start_date) if end_date: query += " AND DATE(i.issued_at) <= %s" params.append(end_date) if status: query += " AND i.status = %s" params.append(status) if client_id: query += " AND i.client_id = %s" params.append(client_id) query += " ORDER BY i.id ASC" if limit_count: try: limit_int = int(limit_count) if limit_int > 0: query += " LIMIT %s" params.append(limit_int) except ValueError: pass cursor.execute(query, tuple(params)) invoices = cursor.fetchall() conn.close() settings = get_app_settings() def build_invoice_pdf_bytes(invoice, settings): buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=letter) width, height = letter left = 50 right = 560 y = height - 50 def money(value, currency="CAD"): return f"{to_decimal(value):.2f} {currency}" pdf.setTitle(f"Invoice {invoice['invoice_number']}") logo_url = (settings.get("business_logo_url") or "").strip() if logo_url.startswith("/static/"): local_logo_path = str(BASE_DIR) + logo_url try: pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') except Exception: pass pdf.setFont("Helvetica-Bold", 22) pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") pdf.setFont("Helvetica-Bold", 14) pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") y -= 18 pdf.setFont("Helvetica", 12) pdf.drawRightString(right, y, settings.get("business_tagline") or "") y -= 15 right_lines = [ settings.get("business_address", ""), settings.get("business_email", ""), settings.get("business_phone", ""), settings.get("business_website", ""), ] for item in right_lines: if item: pdf.drawRightString(right, y, item[:80]) y -= 14 y -= 10 pdf.setFont("Helvetica-Bold", 12) pdf.drawString(left, y, "Status:") pdf.setFont("Helvetica", 12) pdf.drawString(left + 45, y, str(invoice["status"]).upper()) y -= 28 pdf.setFont("Helvetica-Bold", 13) pdf.drawString(left, y, "Bill To") y -= 20 pdf.setFont("Helvetica-Bold", 12) pdf.drawString(left, y, invoice["company_name"] or "") y -= 16 pdf.setFont("Helvetica", 11) if invoice.get("contact_name"): pdf.drawString(left, y, str(invoice["contact_name"])) y -= 15 if invoice.get("email"): pdf.drawString(left, y, str(invoice["email"])) y -= 15 if invoice.get("phone"): pdf.drawString(left, y, str(invoice["phone"])) y -= 15 pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") y -= 28 pdf.setFont("Helvetica-Bold", 13) pdf.drawString(left, y, "Invoice Details") y -= 20 pdf.setFont("Helvetica", 11) pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") y -= 15 pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") y -= 15 pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") y -= 15 if invoice.get("paid_at"): pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") y -= 15 pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") y -= 28 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(left, y, "Service Code") pdf.drawString(180, y, "Service") pdf.drawString(330, y, "Description") pdf.drawRightString(right, y, "Total") y -= 14 pdf.line(left, y, right, y) y -= 18 pdf.setFont("Helvetica", 11) pdf.drawString(left, y, str(invoice.get("service_code") or "-")) pdf.drawString(180, y, str(invoice.get("service_name") or "-")) pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) y -= 28 totals_x_label = 360 totals_x_value = right totals = [ ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), ] remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) for label, value in totals: pdf.setFont("Helvetica-Bold", 11) pdf.drawString(totals_x_label, y, label) pdf.setFont("Helvetica", 11) pdf.drawRightString(totals_x_value, y, value) y -= 18 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(totals_x_label, y, "Remaining") pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") y -= 25 if settings.get("tax_number"): pdf.setFont("Helvetica", 10) pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") y -= 14 if settings.get("business_number"): pdf.setFont("Helvetica", 10) pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") y -= 14 if settings.get("payment_terms"): y -= 8 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(left, y, "Payment Terms") y -= 15 pdf.setFont("Helvetica", 10) terms = settings.get("payment_terms", "") for chunk_start in range(0, len(terms), 90): line_text = terms[chunk_start:chunk_start+90] pdf.drawString(left, y, line_text) y -= 13 if settings.get("invoice_footer"): y -= 8 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(left, y, "Footer") y -= 15 pdf.setFont("Helvetica", 10) footer = settings.get("invoice_footer", "") for chunk_start in range(0, len(footer), 90): line_text = footer[chunk_start:chunk_start+90] pdf.drawString(left, y, line_text) y -= 13 pdf.showPage() pdf.save() buffer.seek(0) return buffer.getvalue() zip_buffer = BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: for invoice in invoices: pdf_bytes = build_invoice_pdf_bytes(invoice, settings) zipf.writestr(f"{invoice['invoice_number']}.pdf", pdf_bytes) zip_buffer.seek(0) filename = "invoices_export" if start_date: filename += f"_{start_date}" if end_date: filename += f"_to_{end_date}" if status: filename += f"_{status}" if client_id: filename += f"_client_{client_id}" if limit_count: filename += f"_limit_{limit_count}" filename += ".zip" return send_file( zip_buffer, mimetype="application/zip", as_attachment=True, download_name=filename ) @app.route("/invoices/print") def print_invoices(): refresh_overdue_invoices() start_date = (request.args.get("start_date") or "").strip() end_date = (request.args.get("end_date") or "").strip() status = (request.args.get("status") or "").strip() client_id = (request.args.get("client_id") or "").strip() limit_count = (request.args.get("limit") or "").strip() conn = get_db_connection() cursor = conn.cursor(dictionary=True) query = """ SELECT i.*, c.client_code, c.company_name, c.contact_name, c.email, c.phone, s.service_code, s.service_name FROM invoices i JOIN clients c ON i.client_id = c.id LEFT JOIN services s ON i.service_id = s.id WHERE 1=1 """ params = [] if start_date: query += " AND DATE(i.issued_at) >= %s" params.append(start_date) if end_date: query += " AND DATE(i.issued_at) <= %s" params.append(end_date) if status: query += " AND i.status = %s" params.append(status) if client_id: query += " AND i.client_id = %s" params.append(client_id) query += " ORDER BY i.id ASC" if limit_count: try: limit_int = int(limit_count) if limit_int > 0: query += " LIMIT %s" params.append(limit_int) except ValueError: pass cursor.execute(query, tuple(params)) invoices = cursor.fetchall() conn.close() settings = get_app_settings() filters = { "start_date": start_date, "end_date": end_date, "status": status, "client_id": client_id, "limit": limit_count, } return render_template("invoices/print_batch.html", invoices=invoices, settings=settings, filters=filters) @app.route("/invoices") def invoices(): refresh_overdue_invoices() start_date = (request.args.get("start_date") or "").strip() end_date = (request.args.get("end_date") or "").strip() status = (request.args.get("status") or "").strip() client_id = (request.args.get("client_id") or "").strip() limit_count = (request.args.get("limit") or "").strip() conn = get_db_connection() cursor = conn.cursor(dictionary=True) query = """ SELECT i.*, c.client_code, c.company_name, COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id AND p.payment_status = 'confirmed'), 0) AS payment_count FROM invoices i JOIN clients c ON i.client_id = c.id WHERE 1=1 """ params = [] if start_date: query += " AND DATE(i.issued_at) >= %s" params.append(start_date) if end_date: query += " AND DATE(i.issued_at) <= %s" params.append(end_date) if status: query += " AND i.status = %s" params.append(status) if client_id: query += " AND i.client_id = %s" params.append(client_id) query += " ORDER BY i.id DESC" if limit_count: try: limit_int = int(limit_count) if limit_int > 0: query += " LIMIT %s" params.append(limit_int) except ValueError: pass cursor.execute(query, tuple(params)) invoices = cursor.fetchall() cursor.execute(""" SELECT id, client_code, company_name FROM clients ORDER BY company_name ASC """) clients = cursor.fetchall() conn.close() filters = { "start_date": start_date, "end_date": end_date, "status": status, "client_id": client_id, "limit": limit_count, } return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients) @app.route("/invoices/new", methods=["GET", "POST"]) def new_invoice(): ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": client_id = request.form.get("client_id", "").strip() service_id = request.form.get("service_id", "").strip() currency_code = request.form.get("currency_code", "").strip() total_amount = request.form.get("total_amount", "").strip() due_at = request.form.get("due_at", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not service_id: errors.append("Service is required.") if not currency_code: errors.append("Currency is required.") if not total_amount: errors.append("Total amount is required.") if not due_at: errors.append("Due date is required.") if not errors: try: amount_value = float(total_amount) if amount_value <= 0: errors.append("Total amount must be greater than zero.") except ValueError: errors.append("Total amount must be a valid number.") if errors: cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") clients = cursor.fetchall() cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") services = cursor.fetchall() conn.close() form_data = { "client_id": client_id, "service_id": service_id, "currency_code": currency_code, "total_amount": total_amount, "due_at": due_at, "notes": notes, } return render_template( "invoices/new.html", clients=clients, services=services, errors=errors, form_data=form_data, ) invoice_number = generate_invoice_number() cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) service_row = cursor.fetchone() service_name = (service_row or {}).get("service_name") or "Service" line_description = service_name if notes: line_description = f"{service_name} - {notes}" oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount) oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at")) quote_fiat_amount = total_amount if oracle_snapshot else None quote_fiat_currency = currency_code if oracle_snapshot else None insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO invoices ( client_id, service_id, invoice_number, currency_code, total_amount, subtotal_amount, issued_at, due_at, status, notes, quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot ) VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s) """, ( client_id, service_id, invoice_number, currency_code, total_amount, total_amount, due_at, notes, quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot_json )) invoice_id = insert_cursor.lastrowid insert_cursor.execute(""" INSERT INTO invoice_items ( invoice_id, line_number, item_type, description, quantity, unit_amount, line_total, currency_code, service_id ) VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) """, ( invoice_id, line_description, total_amount, total_amount, currency_code, service_id )) conn.commit() conn.close() return redirect("/invoices") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") clients = cursor.fetchall() cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") services = cursor.fetchall() conn.close() return render_template( "invoices/new.html", clients=clients, services=services, errors=[], form_data={}, ) @app.route("/invoices/pdf/") def invoice_pdf(invoice_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, c.client_code, c.company_name, c.contact_name, c.email, c.phone, s.service_code, s.service_name FROM invoices i JOIN clients c ON i.client_id = c.id LEFT JOIN services s ON i.service_id = s.id WHERE i.id = %s """, (invoice_id,)) invoice = cursor.fetchone() if not invoice: conn.close() return "Invoice not found", 404 conn.close() settings = get_app_settings() buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=letter) width, height = letter left = 50 right = 560 y = height - 50 def draw_line(txt, x=left, font="Helvetica", size=11): nonlocal y pdf.setFont(font, size) pdf.drawString(x, y, str(txt) if txt is not None else "") y -= 16 def money(value, currency="CAD"): return f"{to_decimal(value):.2f} {currency}" pdf.setTitle(f"Invoice {invoice['invoice_number']}") logo_url = (settings.get("business_logo_url") or "").strip() if logo_url.startswith("/static/"): local_logo_path = str(BASE_DIR) + logo_url try: pdf.drawImage(ImageReader(local_logo_path), left, y - 35, width=42, height=42, preserveAspectRatio=True, mask='auto') except Exception: pass pdf.setFont("Helvetica-Bold", 22) pdf.drawString(left + 60, y, f"Invoice {invoice['invoice_number']}") pdf.setFont("Helvetica-Bold", 14) pdf.drawRightString(right, y, settings.get("business_name") or "OTB Billing") y -= 18 pdf.setFont("Helvetica", 12) pdf.drawRightString(right, y, settings.get("business_tagline") or "") y -= 15 right_lines = [ settings.get("business_address", ""), settings.get("business_email", ""), settings.get("business_phone", ""), settings.get("business_website", ""), ] for item in right_lines: if item: pdf.drawRightString(right, y, item[:80]) y -= 14 y -= 10 pdf.setFont("Helvetica-Bold", 12) pdf.drawString(left, y, "Status:") pdf.setFont("Helvetica", 12) pdf.drawString(left + 45, y, str(invoice["status"]).upper()) y -= 28 pdf.setFont("Helvetica-Bold", 13) pdf.drawString(left, y, "Bill To") y -= 20 pdf.setFont("Helvetica-Bold", 12) pdf.drawString(left, y, invoice["company_name"] or "") y -= 16 pdf.setFont("Helvetica", 11) if invoice.get("contact_name"): pdf.drawString(left, y, str(invoice["contact_name"])) y -= 15 if invoice.get("email"): pdf.drawString(left, y, str(invoice["email"])) y -= 15 if invoice.get("phone"): pdf.drawString(left, y, str(invoice["phone"])) y -= 15 pdf.drawString(left, y, f"Client Code: {invoice.get('client_code', '')}") y -= 28 pdf.setFont("Helvetica-Bold", 13) pdf.drawString(left, y, "Invoice Details") y -= 20 pdf.setFont("Helvetica", 11) pdf.drawString(left, y, f"Invoice #: {invoice['invoice_number']}") y -= 15 pdf.drawString(left, y, f"Issued: {fmt_local(invoice.get('issued_at'))}") y -= 15 pdf.drawString(left, y, f"Due: {fmt_local(invoice.get('due_at'))}") y -= 15 if invoice.get("paid_at"): pdf.drawString(left, y, f"Paid: {fmt_local(invoice.get('paid_at'))}") y -= 15 pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") y -= 28 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(left, y, "Service Code") pdf.drawString(180, y, "Service") pdf.drawString(330, y, "Description") pdf.drawRightString(right, y, "Total") y -= 14 pdf.line(left, y, right, y) y -= 18 pdf.setFont("Helvetica", 11) pdf.drawString(left, y, str(invoice.get("service_code") or "-")) pdf.drawString(180, y, str(invoice.get("service_name") or "-")) pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) y -= 28 totals_x_label = 360 totals_x_value = right totals = [ ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), ] remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) for label, value in totals: pdf.setFont("Helvetica-Bold", 11) pdf.drawString(totals_x_label, y, label) pdf.setFont("Helvetica", 11) pdf.drawRightString(totals_x_value, y, value) y -= 18 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(totals_x_label, y, "Remaining") pdf.drawRightString(totals_x_value, y, f"{remaining:.2f} {invoice.get('currency_code', 'CAD')}") y -= 25 if settings.get("tax_number"): pdf.setFont("Helvetica", 10) pdf.drawString(left, y, f"{settings.get('tax_label') or 'Tax'} Number: {settings.get('tax_number')}") y -= 14 if settings.get("business_number"): pdf.setFont("Helvetica", 10) pdf.drawString(left, y, f"Business Number: {settings.get('business_number')}") y -= 14 if settings.get("payment_terms"): y -= 8 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(left, y, "Payment Terms") y -= 15 pdf.setFont("Helvetica", 10) for chunk_start in range(0, len(settings.get("payment_terms", "")), 90): line_text = settings.get("payment_terms", "")[chunk_start:chunk_start+90] pdf.drawString(left, y, line_text) y -= 13 if 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): ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, c.client_code, c.company_name, c.contact_name, c.email, c.phone, s.service_code, s.service_name FROM invoices 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 invoice["oracle_quote"] = None invoice["quote_expires_at_local"] = fmt_local(invoice.get("quote_expires_at")) if invoice.get("oracle_snapshot"): try: invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) except Exception: invoice["oracle_quote"] = None conn.close() settings = get_app_settings() return render_template("invoices/view.html", invoice=invoice, settings=settings) @app.route("/invoices/edit/", methods=["GET", "POST"]) def edit_invoice(invoice_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, COALESCE((SELECT COUNT(*) FROM payments p WHERE p.invoice_id = i.id), 0) AS payment_count FROM invoices i WHERE i.id = %s """, (invoice_id,)) invoice = cursor.fetchone() if not invoice: conn.close() return "Invoice not found", 404 locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 if request.method == "POST": due_at = request.form.get("due_at", "").strip() notes = request.form.get("notes", "").strip() if locked: update_cursor = conn.cursor() update_cursor.execute(""" UPDATE invoices SET due_at = %s, notes = %s WHERE id = %s """, ( due_at or None, notes or None, invoice_id )) conn.commit() conn.close() return redirect("/invoices") client_id = request.form.get("client_id", "").strip() service_id = request.form.get("service_id", "").strip() currency_code = request.form.get("currency_code", "").strip() total_amount = request.form.get("total_amount", "").strip() status = request.form.get("status", "").strip() errors = [] if not client_id: errors.append("Client is required.") if not service_id: errors.append("Service is required.") if not currency_code: errors.append("Currency is required.") if not total_amount: errors.append("Total amount is required.") if not due_at: errors.append("Due date is required.") if not status: errors.append("Status is required.") manual_statuses = {"draft", "pending", "cancelled"} if status and status not in manual_statuses: errors.append("Manual invoice status must be draft, pending, or cancelled.") if not errors: try: amount_value = float(total_amount) if amount_value < 0: errors.append("Total amount cannot be negative.") except ValueError: errors.append("Total amount must be a valid number.") cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") clients = cursor.fetchall() cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") services = cursor.fetchall() if errors: invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] invoice["currency_code"] = currency_code or invoice["currency_code"] invoice["total_amount"] = total_amount or invoice["total_amount"] invoice["due_at"] = due_at or invoice["due_at"] invoice["status"] = status or invoice["status"] invoice["notes"] = notes conn.close() return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,)) service_row = cursor.fetchone() service_name = (service_row or {}).get("service_name") or "Service" line_description = service_name if notes: line_description = f"{service_name} - {notes}" update_cursor = conn.cursor() update_cursor.execute(""" UPDATE invoices SET client_id = %s, service_id = %s, currency_code = %s, total_amount = %s, subtotal_amount = %s, due_at = %s, status = %s, notes = %s WHERE id = %s """, ( client_id, service_id, currency_code, total_amount, total_amount, due_at, status, notes or None, invoice_id )) update_cursor.execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_id,)) update_cursor.execute(""" INSERT INTO invoice_items ( invoice_id, line_number, item_type, description, quantity, unit_amount, line_total, currency_code, service_id ) VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) """, ( invoice_id, line_description, total_amount, total_amount, currency_code, service_id )) conn.commit() conn.close() return redirect("/invoices") clients = [] services = [] if not locked: cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") clients = cursor.fetchall() cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") services = cursor.fetchall() conn.close() return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) @app.route("/payments/export.csv") def export_payments_csv(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT p.id, p.invoice_id, i.invoice_number, p.client_id, c.client_code, c.company_name, p.payment_method, p.payment_currency, p.payment_amount, p.cad_value_at_payment, p.reference, p.sender_name, p.txid, p.wallet_address, p.payment_status, p.received_at, p.notes FROM payments p JOIN invoices i ON p.invoice_id = i.id JOIN clients c ON p.client_id = c.id ORDER BY p.id ASC """) rows = cursor.fetchall() conn.close() output = StringIO() writer = csv.writer(output) writer.writerow([ "id", "invoice_id", "invoice_number", "client_id", "client_code", "company_name", "payment_method", "payment_currency", "payment_amount", "cad_value_at_payment", "reference", "sender_name", "txid", "wallet_address", "payment_status", "received_at", "notes", ]) for r in rows: writer.writerow([ r.get("id", ""), r.get("invoice_id", ""), r.get("invoice_number", ""), r.get("client_id", ""), r.get("client_code", ""), r.get("company_name", ""), r.get("payment_method", ""), r.get("payment_currency", ""), r.get("payment_amount", ""), r.get("cad_value_at_payment", ""), r.get("reference", ""), r.get("sender_name", ""), r.get("txid", ""), r.get("wallet_address", ""), r.get("payment_status", ""), r.get("received_at", ""), r.get("notes", ""), ]) response = make_response(output.getvalue()) response.headers["Content-Type"] = "text/csv; charset=utf-8" response.headers["Content-Disposition"] = "attachment; filename=payments.csv" return response @app.route("/payments") def payments(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT p.*, i.invoice_number, i.status AS invoice_status, i.total_amount, i.amount_paid, i.currency_code AS invoice_currency_code, c.client_code, c.company_name FROM payments p JOIN invoices i ON p.invoice_id = i.id JOIN clients c ON p.client_id = c.id ORDER BY p.id DESC """) payments = cursor.fetchall() conn.close() return render_template("payments/list.html", payments=payments) @app.route("/payments/new", methods=["GET", "POST"]) def new_payment(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) if request.method == "POST": invoice_id = request.form.get("invoice_id", "").strip() payment_method = request.form.get("payment_method", "").strip() payment_currency = request.form.get("payment_currency", "").strip() payment_amount = request.form.get("payment_amount", "").strip() cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() reference = request.form.get("reference", "").strip() sender_name = request.form.get("sender_name", "").strip() txid = request.form.get("txid", "").strip() wallet_address = request.form.get("wallet_address", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not invoice_id: errors.append("Invoice is required.") if not payment_method: errors.append("Payment method is required.") if not payment_currency: errors.append("Payment currency is required.") if not payment_amount: errors.append("Payment amount is required.") if not cad_value_at_payment: errors.append("CAD value at payment is required.") if not errors: try: payment_amount_value = Decimal(str(payment_amount)) if payment_amount_value <= Decimal("0"): errors.append("Payment amount must be greater than zero.") except Exception: errors.append("Payment amount must be a valid number.") if not errors: try: cad_value_value = Decimal(str(cad_value_at_payment)) if cad_value_value < Decimal("0"): errors.append("CAD value at payment cannot be negative.") except Exception: errors.append("CAD value at payment must be a valid number.") invoice_row = None if not errors: cursor.execute(""" SELECT i.id, i.client_id, i.invoice_number, i.currency_code, i.total_amount, i.amount_paid, i.status, c.client_code, c.company_name FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.id = %s """, (invoice_id,)) invoice_row = cursor.fetchone() if not invoice_row: errors.append("Selected invoice was not found.") else: allowed_statuses = {"pending", "partial", "overdue"} if invoice_row["status"] not in allowed_statuses: errors.append("Payments can only be recorded against pending, partial, or overdue invoices.") else: remaining_balance = to_decimal(invoice_row["total_amount"]) - to_decimal(invoice_row["amount_paid"]) entered_amount = to_decimal(payment_amount) if remaining_balance <= Decimal("0"): errors.append("This invoice has no remaining balance.") elif entered_amount > remaining_balance: errors.append( f"Payment amount exceeds remaining balance. Remaining balance is {fmt_money(remaining_balance, invoice_row['currency_code'])} {invoice_row['currency_code']}." ) if errors: cursor.execute(""" SELECT i.id, i.invoice_number, i.currency_code, i.total_amount, i.amount_paid, i.status, c.client_code, c.company_name FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.status IN ('pending', 'partial', 'overdue') AND (i.total_amount - i.amount_paid) > 0 ORDER BY i.id DESC """) invoices = cursor.fetchall() conn.close() form_data = { "invoice_id": invoice_id, "payment_method": payment_method, "payment_currency": payment_currency, "payment_amount": payment_amount, "cad_value_at_payment": cad_value_at_payment, "reference": reference, "sender_name": sender_name, "txid": txid, "wallet_address": wallet_address, "notes": notes, } return render_template( "payments/new.html", invoices=invoices, errors=errors, form_data=form_data, ) client_id = invoice_row["client_id"] insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO payments ( invoice_id, client_id, payment_method, payment_currency, payment_amount, cad_value_at_payment, reference, sender_name, txid, wallet_address, payment_status, received_at, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'confirmed', UTC_TIMESTAMP(), %s) """, ( invoice_id, client_id, payment_method, payment_currency, payment_amount, cad_value_at_payment, reference or None, sender_name or None, txid or None, wallet_address or None, notes or None )) conn.commit() conn.close() recalc_invoice_totals(invoice_id) try: notify_conn = get_db_connection() notify_cursor = notify_conn.cursor(dictionary=True) notify_cursor.execute(""" SELECT i.id, i.invoice_number, i.status, i.total_amount, i.amount_paid, i.currency_code, c.company_name, c.contact_name, c.email FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.id = %s LIMIT 1 """, (invoice_id,)) invoice_email_row = notify_cursor.fetchone() notify_conn.close() if invoice_email_row and invoice_email_row.get("email"): client_name = ( invoice_email_row.get("contact_name") or invoice_email_row.get("company_name") or invoice_email_row.get("email") ) payment_amount_display = f"{to_decimal(cad_value_at_payment):.2f} CAD" invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" subject = f"Payment Received for Invoice {invoice_email_row.get('invoice_number')}" body = f"""Hello {client_name}, We have received your payment for invoice {invoice_email_row.get('invoice_number')}. Amount Received: {payment_amount_display} Invoice Total: {invoice_total_display} Current Invoice Status: {invoice_email_row.get('status')} You can view your invoice anytime in the client portal: https://portal.outsidethebox.top/portal Thank you, OutsideTheBox support@outsidethebox.top """ send_configured_email( to_email=invoice_email_row.get("email"), subject=subject, body=body, attachments=None, email_type="payment_received", invoice_id=invoice_id ) except Exception: pass return redirect("/payments") cursor.execute(""" SELECT i.id, i.invoice_number, i.currency_code, i.total_amount, i.amount_paid, i.status, c.client_code, c.company_name FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.status IN ('pending', 'partial', 'overdue') AND (i.total_amount - i.amount_paid) > 0 ORDER BY i.id DESC """) invoices = cursor.fetchall() conn.close() return render_template( "payments/new.html", invoices=invoices, errors=[], form_data={}, ) @app.route("/payments/void/", methods=["POST"]) def void_payment(payment_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, invoice_id, payment_status FROM payments WHERE id = %s """, (payment_id,)) payment = cursor.fetchone() if not payment: conn.close() return "Payment not found", 404 if payment["payment_status"] != "confirmed": conn.close() return redirect("/payments") update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_status = 'reversed' WHERE id = %s """, (payment_id,)) conn.commit() conn.close() recalc_invoice_totals(payment["invoice_id"]) return redirect("/payments") recalc_invoice_totals(payment["invoice_id"]) return redirect("/payments") @app.route("/payments/edit/", methods=["GET", "POST"]) def edit_payment(payment_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT p.*, i.invoice_number, c.client_code, c.company_name FROM payments p JOIN invoices i ON p.invoice_id = i.id JOIN clients c ON p.client_id = c.id WHERE p.id = %s """, (payment_id,)) payment = cursor.fetchone() if not payment: conn.close() return "Payment not found", 404 if request.method == "POST": payment_method = request.form.get("payment_method", "").strip() payment_currency = request.form.get("payment_currency", "").strip() payment_amount = request.form.get("payment_amount", "").strip() cad_value_at_payment = request.form.get("cad_value_at_payment", "").strip() reference = request.form.get("reference", "").strip() sender_name = request.form.get("sender_name", "").strip() txid = request.form.get("txid", "").strip() wallet_address = request.form.get("wallet_address", "").strip() notes = request.form.get("notes", "").strip() errors = [] if not payment_method: errors.append("Payment method is required.") if not payment_currency: errors.append("Payment currency is required.") if not payment_amount: errors.append("Payment amount is required.") if not cad_value_at_payment: errors.append("CAD value at payment is required.") if not errors: try: amount_value = float(payment_amount) if amount_value <= 0: errors.append("Payment amount must be greater than zero.") except ValueError: errors.append("Payment amount must be a valid number.") try: cad_value = float(cad_value_at_payment) if cad_value < 0: errors.append("CAD value at payment cannot be negative.") except ValueError: errors.append("CAD value at payment must be a valid number.") if errors: payment["payment_method"] = payment_method or payment["payment_method"] payment["payment_currency"] = payment_currency or payment["payment_currency"] payment["payment_amount"] = payment_amount or payment["payment_amount"] payment["cad_value_at_payment"] = cad_value_at_payment or payment["cad_value_at_payment"] payment["reference"] = reference payment["sender_name"] = sender_name payment["txid"] = txid payment["wallet_address"] = wallet_address payment["notes"] = notes conn.close() return render_template("payments/edit.html", payment=payment, errors=errors) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET payment_method = %s, payment_currency = %s, payment_amount = %s, cad_value_at_payment = %s, reference = %s, sender_name = %s, txid = %s, wallet_address = %s, notes = %s WHERE id = %s """, ( payment_method, payment_currency, payment_amount, cad_value_at_payment, reference or None, sender_name or None, txid or None, wallet_address or None, notes or None, payment_id )) conn.commit() invoice_id = payment["invoice_id"] conn.close() recalc_invoice_totals(invoice_id) return redirect("/payments") conn.close() return render_template("payments/edit.html", payment=payment, errors=[]) def _portal_current_client(): client_id = session.get("portal_client_id") if not client_id: return None conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, company_name, contact_name, email, portal_enabled, portal_force_password_change FROM clients WHERE id = %s LIMIT 1 """, (client_id,)) client = cursor.fetchone() conn.close() return client @app.route("/portal", methods=["GET"]) def portal_index(): if session.get("portal_client_id"): return redirect("/portal/dashboard") return render_template("portal_login.html") @app.route("/portal/login", methods=["POST"]) def portal_login(): email = (request.form.get("email") or "").strip().lower() credential = (request.form.get("credential") or "").strip() if not email or not credential: return render_template("portal_login.html", portal_message="Email and access code or password are required.", portal_email=email) conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, portal_password_hash, portal_force_password_change FROM clients WHERE LOWER(email) = %s LIMIT 1 """, (email,)) client = cursor.fetchone() if not client or not client.get("portal_enabled"): conn.close() return render_template("portal_login.html", portal_message="Portal access is not enabled for that email address.", portal_email=email) password_hash = client.get("portal_password_hash") access_code = client.get("portal_access_code") or "" ok = False first_login = False if password_hash: ok = check_password_hash(password_hash, credential) else: ok = (credential == access_code) first_login = ok if not ok and access_code and credential == access_code: ok = True first_login = True if not ok: conn.close() return render_template("portal_login.html", portal_message="Invalid credentials.", portal_email=email) session["portal_client_id"] = client["id"] session["portal_email"] = client["email"] cursor.execute(""" UPDATE clients SET portal_last_login_at = UTC_TIMESTAMP() WHERE id = %s """, (client["id"],)) conn.commit() conn.close() if first_login or client.get("portal_force_password_change"): return redirect("/portal/set-password") return redirect("/portal/dashboard") @app.route("/portal/set-password", methods=["GET", "POST"]) def portal_set_password(): client = _portal_current_client() if not client: return redirect("/portal") client_name = client.get("company_name") or client.get("contact_name") or client.get("email") if request.method == "GET": return render_template("portal_set_password.html", client_name=client_name) password = (request.form.get("password") or "") password2 = (request.form.get("password2") or "") if len(password) < 10: return render_template("portal_set_password.html", client_name=client_name, portal_message="Password must be at least 10 characters long.") if password != password2: return render_template("portal_set_password.html", client_name=client_name, portal_message="Passwords do not match.") conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" UPDATE clients SET portal_password_hash = %s, portal_password_set_at = UTC_TIMESTAMP(), portal_force_password_change = 0, portal_access_code = NULL WHERE id = %s """, (generate_password_hash(password), client["id"])) conn.commit() conn.close() return redirect("/portal/dashboard") @app.route("/portal/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") conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, invoice_number, status, created_at, total_amount, amount_paid FROM invoices WHERE client_id = %s ORDER BY created_at DESC """, (client["id"],)) invoices = cursor.fetchall() def _fmt_money(value): return f"{to_decimal(value):.2f}" for row in invoices: outstanding = to_decimal(row.get("total_amount")) - to_decimal(row.get("amount_paid")) row["outstanding"] = _fmt_money(outstanding) row["total_amount"] = _fmt_money(row.get("total_amount")) row["amount_paid"] = _fmt_money(row.get("amount_paid")) row["created_at"] = fmt_local(row.get("created_at")) total_outstanding = sum((to_decimal(r["outstanding"]) for r in invoices), to_decimal("0")) total_paid = sum((to_decimal(r["amount_paid"]) for r in invoices), to_decimal("0")) conn.close() return render_template( "portal_dashboard.html", client=client, invoices=invoices, invoice_count=len(invoices), total_outstanding=f"{total_outstanding:.2f}", total_paid=f"{total_paid:.2f}", ) @app.route("/portal/invoice//pdf", methods=["GET"]) def portal_invoice_pdf(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.client_code, c.company_name, c.contact_name, c.email, c.phone, s.service_code, s.service_name FROM invoices 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") 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 = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) invoice["outstanding"] = _fmt_money(outstanding) invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) invoice["created_at"] = fmt_local(invoice.get("created_at")) 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 = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) invoice["outstanding"] = _fmt_money(outstanding) invoice["total_amount"] = _fmt_money(invoice.get("total_amount")) invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid")) 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}" 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, ) @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): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, portal_enabled, portal_access_code, portal_password_hash FROM clients WHERE id = %s LIMIT 1 """, (client_id,)) client = cursor.fetchone() if not client: conn.close() return redirect("/clients") if not client.get("portal_access_code") and not client.get("portal_password_hash"): new_code = generate_portal_access_code() cursor2 = conn.cursor() cursor2.execute(""" UPDATE clients SET portal_enabled = 1, portal_access_code = %s, portal_access_code_created_at = UTC_TIMESTAMP(), portal_force_password_change = 1 WHERE id = %s """, (new_code, client_id)) else: cursor2 = conn.cursor() cursor2.execute(""" UPDATE clients SET portal_enabled = 1 WHERE id = %s """, (client_id,)) conn.commit() conn.close() return redirect(f"/clients/edit/{client_id}") @app.route("/clients/portal/disable/", methods=["POST"]) def client_portal_disable(client_id): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" UPDATE clients SET portal_enabled = 0 WHERE id = %s """, (client_id,)) conn.commit() conn.close() return redirect(f"/clients/edit/{client_id}") @app.route("/clients/portal/reset-code/", methods=["POST"]) def client_portal_reset_code(client_id): new_code = generate_portal_access_code() conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" UPDATE clients SET portal_enabled = 1, portal_access_code = %s, portal_access_code_created_at = UTC_TIMESTAMP(), portal_password_hash = NULL, portal_password_set_at = NULL, portal_force_password_change = 1 WHERE id = %s """, (new_code, client_id)) conn.commit() conn.close() return redirect(f"/clients/edit/{client_id}") @app.route("/clients/portal/send-invite/", methods=["POST"]) def client_portal_send_invite(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, portal_password_hash, portal_password_set_at FROM clients WHERE id = %s LIMIT 1 """, (client_id,)) client = cursor.fetchone() if not client: conn.close() return redirect("/clients") if not client.get("email"): conn.close() return redirect(f"/clients/edit/{client_id}?portal_email_status=missing_email") access_code = client.get("portal_access_code") # If no active one-time code exists, generate a fresh one and require password setup again. if not access_code: access_code = generate_portal_access_code() update_cursor = conn.cursor() update_cursor.execute(""" UPDATE clients SET portal_enabled = 1, portal_access_code = %s, portal_access_code_created_at = UTC_TIMESTAMP(), portal_password_hash = NULL, portal_password_set_at = NULL, portal_force_password_change = 1 WHERE id = %s """, (access_code, client_id)) conn.commit() cursor.execute(""" SELECT id, company_name, contact_name, email, portal_enabled, portal_access_code, portal_password_hash, portal_password_set_at FROM clients WHERE id = %s LIMIT 1 """, (client_id,)) client = cursor.fetchone() elif not client.get("portal_enabled"): update_cursor = conn.cursor() update_cursor.execute(""" UPDATE clients SET portal_enabled = 1 WHERE id = %s """, (client_id,)) conn.commit() conn.close() contact_name = client.get("contact_name") or client.get("company_name") or "Client" portal_email = client.get("email") or "" portal_url = "https://portal.outsidethebox.top" support_email = "support@outsidethebox.top" subject = "Your OutsideTheBox Client Portal Access" body = f"""Hello {contact_name}, Your OutsideTheBox client portal access is now ready. Portal URL: {portal_url} Login email: {portal_email} Single-use access code: {client.get("portal_access_code")} Important: - This access code is single-use. - After your first successful login, you will be asked to create your password. - Once your password is created, this access code is cleared and future logins will use your email address and password. If you have any trouble signing in, contact support: {support_email} Regards, OutsideTheBox """ try: send_configured_email( to_email=portal_email, subject=subject, body=body, attachments=None, email_type="portal_invite", invoice_id=None ) return redirect(f"/clients/edit/{client_id}?portal_email_status=sent") except Exception: return redirect(f"/clients/edit/{client_id}?portal_email_status=error") @app.route("/clients/portal/send-password-reset/", methods=["POST"]) def client_portal_send_password_reset(client_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, company_name, contact_name, email, portal_enabled FROM clients WHERE id = %s LIMIT 1 """, (client_id,)) client = cursor.fetchone() if not client: conn.close() return redirect("/clients") if not client.get("email"): conn.close() return redirect(f"/clients/edit/{client_id}?portal_reset_status=missing_email") new_code = generate_portal_access_code() update_cursor = conn.cursor() update_cursor.execute(""" UPDATE clients SET portal_enabled = 1, portal_access_code = %s, portal_access_code_created_at = UTC_TIMESTAMP(), portal_password_hash = NULL, portal_password_set_at = NULL, portal_force_password_change = 1 WHERE id = %s """, (new_code, client_id)) conn.commit() conn.close() contact_name = client.get("contact_name") or client.get("company_name") or "Client" portal_email = client.get("email") or "" portal_url = "https://portal.outsidethebox.top" support_email = "support@outsidethebox.top" subject = "Your OutsideTheBox Portal Password Reset" body = f"""Hello {contact_name}, A password reset has been issued for your OutsideTheBox client portal access. Portal URL: {portal_url} Login email: {portal_email} New single-use access code: {new_code} Important: - This access code is single-use. - It replaces your previous portal password. - After you sign in, you will be asked to create a new password. - Once your new password is created, this access code is cleared and future logins will use your email address and password. If you did not expect this reset, contact support immediately: {support_email} Regards, OutsideTheBox """ try: send_configured_email( to_email=portal_email, subject=subject, body=body, attachments=None, email_type="portal_password_reset", invoice_id=None ) return redirect(f"/clients/edit/{client_id}?portal_reset_status=sent") except Exception: return redirect(f"/clients/edit/{client_id}?portal_reset_status=error") @app.route("/portal/forgot-password", methods=["GET", "POST"]) def portal_forgot_password(): if request.method == "GET": return render_template("portal_forgot_password.html", error=None, message=None, form_email="") email = (request.form.get("email") or "").strip().lower() if not email: return render_template( "portal_forgot_password.html", error="Email address is required.", message=None, form_email="" ) conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, company_name, contact_name, email FROM clients WHERE LOWER(email) = %s LIMIT 1 """, (email,)) client = cursor.fetchone() if client: new_code = generate_portal_access_code() update_cursor = conn.cursor() update_cursor.execute(""" UPDATE clients SET portal_access_code = %s, portal_access_code_created_at = UTC_TIMESTAMP(), portal_password_hash = NULL, portal_password_set_at = NULL, portal_force_password_change = 1, portal_enabled = 1 WHERE id = %s """, (new_code, client["id"])) conn.commit() contact_name = client.get("contact_name") or client.get("company_name") or "Client" portal_url = "https://portal.outsidethebox.top" support_email = "support@outsidethebox.top" subject = "Your OutsideTheBox Portal Password Reset" body = f"""Hello {contact_name}, A password reset was requested for your OutsideTheBox client portal. Portal URL: {portal_url} Login email: {client.get("email")} Single-use access code: {new_code} Important: - This access code is single-use. - It replaces your previous portal password. - After you sign in, you will be asked to create a new password. - Once your new password is created, this access code is cleared and future logins will use your email address and password. If you did not request this reset, contact support immediately: {support_email} Regards, OutsideTheBox """ try: send_configured_email( to_email=client.get("email"), subject=subject, body=body, attachments=None, email_type="portal_forgot_password", invoice_id=None ) except Exception: pass conn.close() return render_template( "portal_forgot_password.html", error=None, message="If that email exists in our system, a reset message has been sent.", form_email=email ) @app.route("/portal/invoice//pay-crypto", methods=["POST"]) def portal_invoice_pay_crypto(invoice_id): client = _portal_current_client() if not client: return redirect("/portal") ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, client_id, invoice_number, status, total_amount, amount_paid, quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot, created_at FROM invoices WHERE id = %s AND client_id = %s LIMIT 1 """, (invoice_id, client["id"])) invoice = cursor.fetchone() if not invoice: conn.close() return redirect("/portal/dashboard") status = (invoice.get("status") or "").lower() if status == "paid": conn.close() return redirect(f"/portal/invoice/{invoice_id}") invoice["oracle_quote"] = None if invoice.get("oracle_snapshot"): try: invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) except Exception: invoice["oracle_quote"] = None options = get_invoice_crypto_options(invoice) chosen_symbol = (request.form.get("asset") or "").strip().upper() selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None) quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}" now_utc = datetime.now(timezone.utc) stored_start = session.get(quote_key) quote_start_dt = None if stored_start: try: quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00")) if quote_start_dt.tzinfo is None: quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc) except Exception: quote_start_dt = None if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90: session.pop(quote_key, None) conn.close() return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1") if not selected_option: conn.close() return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset") if not selected_option.get("available"): conn.close() return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available") cursor.execute(""" SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes FROM payments WHERE invoice_id = %s AND client_id = %s AND payment_status = 'pending' AND payment_currency = %s ORDER BY id DESC LIMIT 1 """, (invoice_id, client["id"], selected_option["payment_currency"])) existing = cursor.fetchone() pending_payment_id = None if existing: created_dt = existing.get("created_at") if created_dt and created_dt.tzinfo is None: created_dt = created_dt.replace(tzinfo=timezone.utc) if created_dt and (now_utc - created_dt).total_seconds() <= 120: pending_payment_id = existing["id"] if not pending_payment_id: insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO payments ( invoice_id, client_id, payment_method, payment_currency, payment_amount, cad_value_at_payment, reference, sender_name, txid, wallet_address, payment_status, received_at, notes ) VALUES (%s, %s, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s) """, ( invoice["id"], invoice["client_id"], selected_option["payment_currency"], str(selected_option["display_amount"]), str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"), invoice["invoice_number"], client.get("email") or client.get("company_name") or "Portal Client", selected_option["wallet_address"], f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote" )) conn.commit() pending_payment_id = insert_cursor.lastrowid session.pop(quote_key, None) conn.close() return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}") @app.route("/portal/invoice//submit-crypto-tx", methods=["POST"]) def portal_submit_crypto_tx(invoice_id): client = _portal_current_client() if not client: return jsonify({"ok": False, "error": "not_authenticated"}), 401 ensure_invoice_quote_columns() try: payload = request.get_json(force=True) or {} except Exception: payload = {} payment_id = str(payload.get("payment_id") or "").strip() asset = str(payload.get("asset") or "").strip().upper() tx_hash = str(payload.get("tx_hash") or "").strip() if not payment_id.isdigit(): return jsonify({"ok": False, "error": "invalid_payment_id"}), 400 if not asset: return jsonify({"ok": False, "error": "missing_asset"}), 400 if not tx_hash or not tx_hash.startswith("0x"): return jsonify({"ok": False, "error": "missing_tx_hash"}), 400 conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT id, client_id, invoice_number, oracle_snapshot FROM invoices WHERE id = %s AND client_id = %s LIMIT 1 """, (invoice_id, client["id"])) invoice = cursor.fetchone() if not invoice: conn.close() return jsonify({"ok": False, "error": "invoice_not_found"}), 404 invoice["oracle_quote"] = None if invoice.get("oracle_snapshot"): try: invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"]) except Exception: invoice["oracle_quote"] = None crypto_options = get_invoice_crypto_options(invoice) selected_option = next((o for o in crypto_options if o["symbol"] == asset), None) if not selected_option: conn.close() return jsonify({"ok": False, "error": "invalid_asset"}), 400 cursor.execute(""" SELECT id, invoice_id, client_id, payment_currency, payment_status, txid, notes FROM payments WHERE id = %s AND invoice_id = %s AND client_id = %s LIMIT 1 """, (int(payment_id), invoice_id, client["id"])) payment = cursor.fetchone() if not payment: conn.close() return jsonify({"ok": False, "error": "payment_not_found"}), 404 if str(payment.get("payment_currency") or "").upper() != selected_option["payment_currency"]: conn.close() return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 if str(payment.get("payment_status") or "").lower() != "pending": conn.close() return jsonify({"ok": False, "error": "payment_not_pending"}), 400 new_notes = append_payment_note( payment.get("notes"), f"[portal wallet submit] tx hash accepted: {tx_hash}" ) update_cursor = conn.cursor() update_cursor.execute(""" UPDATE payments SET txid = %s, payment_status = 'pending', notes = %s WHERE id = %s """, ( tx_hash, new_notes, payment["id"] )) conn.commit() conn.close() return jsonify({ "ok": True, "redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={asset}&payment_id={payment['id']}" }) @app.route("/portal/invoice//pay-square", methods=["GET"]) def portal_invoice_pay_square(invoice_id): client = _portal_current_client() if not client: return redirect("/portal") conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, c.email AS client_email, c.company_name, c.contact_name FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.id = %s AND i.client_id = %s LIMIT 1 """, (invoice_id, client["id"])) invoice = cursor.fetchone() conn.close() if not invoice: return redirect("/portal/dashboard") status = (invoice.get("status") or "").lower() if status == "paid": return redirect(f"/portal/invoice/{invoice_id}") square_url = create_square_payment_link_for_invoice(invoice, invoice.get("client_email") or "") return redirect(square_url) @app.route("/invoices/pay-square/", methods=["GET"]) def admin_invoice_pay_square(invoice_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT i.*, c.email AS client_email, c.company_name, c.contact_name FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.id = %s LIMIT 1 """, (invoice_id,)) invoice = cursor.fetchone() conn.close() if not invoice: return "Invoice not found", 404 status = (invoice.get("status") or "").lower() if status == "paid": return redirect(f"/invoices/view/{invoice_id}") square_url = create_square_payment_link_for_invoice(invoice, invoice.get("client_email") or "") return redirect(square_url) def auto_apply_square_payment(parsed_event): try: data_obj = (((parsed_event.get("data") or {}).get("object")) or {}) payment = data_obj.get("payment") or {} payment_id = payment.get("id") or "" payment_status = (payment.get("status") or "").upper() note = (payment.get("note") or "").strip() buyer_email = (payment.get("buyer_email_address") or "").strip() amount_money = (payment.get("amount_money") or {}).get("amount") currency = (payment.get("amount_money") or {}).get("currency") or "CAD" if not payment_id or payment_status != "COMPLETED": return {"processed": False, "reason": "not_completed_or_missing_id"} m = re.search(r'Invoice\s+([A-Za-z0-9\-]+)', note, re.IGNORECASE) if not m: return {"processed": False, "reason": "invoice_note_not_found", "note": note} invoice_number = m.group(1).strip() conn = get_db_connection() cursor = conn.cursor(dictionary=True) # Deduplicate by Square payment ID cursor.execute(""" SELECT id FROM payments WHERE txid = %s LIMIT 1 """, (payment_id,)) existing = cursor.fetchone() if existing: conn.close() return {"processed": False, "reason": "duplicate_payment_id", "payment_id": payment_id} cursor.execute(""" SELECT i.id, i.client_id, i.invoice_number, i.currency_code, i.total_amount, i.amount_paid, i.status, c.company_name, c.contact_name, c.email FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.invoice_number = %s LIMIT 1 """, (invoice_number,)) invoice = cursor.fetchone() if not invoice: conn.close() return {"processed": False, "reason": "invoice_not_found", "invoice_number": invoice_number} payment_amount = to_decimal(amount_money) / to_decimal("100") insert_cursor = conn.cursor() insert_cursor.execute(""" INSERT INTO payments ( invoice_id, client_id, payment_method, payment_currency, payment_amount, cad_value_at_payment, reference, sender_name, txid, wallet_address, payment_status, received_at, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s) """, ( invoice["id"], invoice["client_id"], "square", currency, payment_amount, payment_amount if currency == "CAD" else payment_amount, invoice_number, buyer_email or "Square Customer", payment_id, "", "confirmed", f"Auto-recorded from Square webhook. Note: {note or ''}".strip() )) conn.commit() conn.close() recalc_invoice_totals(invoice["id"]) try: notify_conn = get_db_connection() notify_cursor = notify_conn.cursor(dictionary=True) notify_cursor.execute(""" SELECT i.id, i.invoice_number, i.status, i.total_amount, i.amount_paid, i.currency_code, c.company_name, c.contact_name, c.email FROM invoices i JOIN clients c ON i.client_id = c.id WHERE i.id = %s LIMIT 1 """, (invoice["id"],)) invoice_email_row = notify_cursor.fetchone() notify_conn.close() if invoice_email_row and invoice_email_row.get("email"): client_name = ( invoice_email_row.get("contact_name") or invoice_email_row.get("company_name") or invoice_email_row.get("email") ) payment_amount_display = f"{to_decimal(payment_amount):.2f} {currency}" invoice_total_display = f"{to_decimal(invoice_email_row.get('total_amount')):.2f} {invoice_email_row.get('currency_code') or 'CAD'}" subject = f"Payment Received for Invoice {invoice_email_row.get('invoice_number')}" body = f"""Hello {client_name}, We have received your payment for invoice {invoice_email_row.get('invoice_number')}. Amount Received: {payment_amount_display} Invoice Total: {invoice_total_display} Current Invoice Status: {invoice_email_row.get('status')} You can view your invoice anytime in the client portal: https://portal.outsidethebox.top/portal Thank you, OutsideTheBox support@outsidethebox.top """ send_configured_email( to_email=invoice_email_row.get("email"), subject=subject, body=body, attachments=None, email_type="payment_received", invoice_id=invoice["id"] ) except Exception: pass return { "processed": True, "invoice_number": invoice_number, "payment_id": payment_id, "amount": str(payment_amount), "currency": currency, } except Exception as e: return {"processed": False, "reason": "exception", "error": str(e)} @app.route("/accountbook/export.csv") def accountbook_export_csv(): conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute(""" SELECT payment_method, payment_currency, payment_amount, cad_value_at_payment, payment_status, received_at FROM payments WHERE payment_status = 'confirmed' ORDER BY received_at DESC """) payments = cursor.fetchall() conn.close() now_local = datetime.now(LOCAL_TZ) today_str = now_local.strftime("%Y-%m-%d") month_prefix = now_local.strftime("%Y-%m") year_prefix = now_local.strftime("%Y") categories = [ ("cash", "Cash"), ("etransfer", "eTransfer"), ("square", "Square"), ("etho", "ETHO"), ("eti", "ETI"), ("egaz", "EGAZ"), ("eth", "ETH"), ("other", "Other"), ] periods = { "today": {"label": "Today", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, "month": {"label": "This Month", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, "ytd": {"label": "Year to Date", "totals": {k: Decimal("0") for k, _ in categories}, "grand": Decimal("0")}, } def norm_method(method): m = (method or "").strip().lower() if m in ("cash",): return "cash" if m in ("etransfer", "e-transfer", "interac", "interac e-transfer", "email money transfer"): return "etransfer" if m in ("square",): return "square" if m in ("etho",): return "etho" if m in ("eti",): return "eti" if m in ("egaz",): return "egaz" if m in ("eth", "ethereum"): return "eth" return "other" for pay in payments: received = pay.get("received_at") if not received: continue if isinstance(received, str): received_local_str = received[:10] received_month = received[:7] received_year = received[:4] else: if received.tzinfo is None: received = received.replace(tzinfo=timezone.utc) received_local = received.astimezone(LOCAL_TZ) received_local_str = received_local.strftime("%Y-%m-%d") received_month = received_local.strftime("%Y-%m") received_year = received_local.strftime("%Y") bucket = norm_method(pay.get("payment_method")) amount = to_decimal(pay.get("cad_value_at_payment") or pay.get("payment_amount") or "0") if received_year == year_prefix: periods["ytd"]["totals"][bucket] += amount periods["ytd"]["grand"] += amount if received_month == month_prefix: periods["month"]["totals"][bucket] += amount periods["month"]["grand"] += amount if received_local_str == today_str: periods["today"]["totals"][bucket] += amount periods["today"]["grand"] += amount output = StringIO() writer = csv.writer(output) writer.writerow(["Period", "Category", "Total CAD"]) for period_key in ("today", "month", "ytd"): period = periods[period_key] for cat_key, cat_label in categories: writer.writerow([period["label"], cat_label, f"{period['totals'][cat_key]:.2f}"]) writer.writerow([period["label"], "Grand Total", f"{period['grand']:.2f}"]) response = make_response(output.getvalue()) response.headers["Content-Type"] = "text/csv; charset=utf-8" response.headers["Content-Disposition"] = "attachment; filename=accountbook_summary.csv" return response @app.route("/accountbook") def accountbook(): 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) Event Payment ID Amount (cents) Note Sig Valid Auto 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)