You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
5436 lines
175 KiB
5436 lines
175 KiB
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 |
|
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://cloudflare-eth.com") |
|
RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arb1.arbitrum.io/rpc") |
|
|
|
|
|
|
|
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_url_for_chain(chain_name): |
|
chain = str(chain_name or "").lower() |
|
if chain == "ethereum": |
|
return RPC_ETHEREUM_URL |
|
if chain == "arbitrum": |
|
return RPC_ARBITRUM_URL |
|
return None |
|
|
|
def rpc_call(rpc_url, method, params): |
|
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 (data or {}).get("result") |
|
|
|
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_url = get_rpc_url_for_chain(option.get("chain")) |
|
if not rpc_url: |
|
raise RuntimeError("No RPC configured for chain") |
|
|
|
tx = rpc_call(rpc_url, "eth_getTransactionByHash", [tx_hash]) |
|
if not tx: |
|
raise RuntimeError("Transaction hash not found on RPC") |
|
|
|
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") |
|
|
|
return { |
|
"rpc_url": rpc_url, |
|
"tx": tx, |
|
} |
|
|
|
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/<int:invoice_id>", 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/<int:client_id>", 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/<int:client_id>") |
|
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/<int:client_id>", 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/<int:service_id>", 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/<int:invoice_id>") |
|
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/<int:invoice_id>") |
|
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/<int:invoice_id>", 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/<int:payment_id>", 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/<int:payment_id>", 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/<int:invoice_id>/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/<int:invoice_id>", 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 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 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 |
|
) |
|
|
|
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/<int:client_id>", 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/<int:client_id>", 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/<int:client_id>", 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/<int:client_id>", 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/<int:client_id>", 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/<int:invoice_id>/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/<int:invoice_id>/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() |
|
|
|
payload = request.get_json(silent=True) or {} |
|
payment_id = str(payload.get("payment_id") or "").strip() |
|
asset_symbol = 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 re.fullmatch(r"0x[a-fA-F0-9]{64}", tx_hash): |
|
return jsonify({"ok": False, "error": "invalid_tx_hash"}), 400 |
|
|
|
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 |
|
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 |
|
|
|
options = get_invoice_crypto_options(invoice) |
|
selected_option = next((o for o in options if o["symbol"] == asset_symbol), None) |
|
if not selected_option: |
|
conn.close() |
|
return jsonify({"ok": False, "error": "asset_not_allowed"}), 400 |
|
if not selected_option.get("wallet_capable"): |
|
conn.close() |
|
return jsonify({"ok": False, "error": "asset_not_wallet_capable"}), 400 |
|
|
|
cursor.execute(""" |
|
SELECT id, invoice_id, client_id, payment_currency, payment_amount, wallet_address, reference, |
|
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"])) |
|
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() != str(selected_option.get("payment_currency") or "").upper(): |
|
conn.close() |
|
return jsonify({"ok": False, "error": "payment_currency_mismatch"}), 400 |
|
|
|
try: |
|
verify_wallet_transaction(selected_option, tx_hash) |
|
except Exception as err: |
|
conn.close() |
|
return jsonify({"ok": False, "error": "tx_verification_failed", "detail": str(err)}), 400 |
|
|
|
update_cursor = conn.cursor() |
|
update_cursor.execute(""" |
|
UPDATE payments |
|
SET txid = %s, |
|
received_at = UTC_TIMESTAMP(), |
|
notes = CONCAT(COALESCE(notes, ''), %s) |
|
WHERE id = %s |
|
""", ( |
|
tx_hash, |
|
f" | tx_submitted | rpc_seen | chain:{selected_option['chain']} | asset:{selected_option['symbol']}", |
|
payment["id"] |
|
)) |
|
conn.commit() |
|
conn.close() |
|
|
|
return jsonify({ |
|
"ok": True, |
|
"payment_id": payment["id"], |
|
"tx_hash": tx_hash, |
|
"chain": selected_option["chain"], |
|
"asset": selected_option["symbol"], |
|
"state": "submitted", |
|
"redirect_url": f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={payment['id']}" |
|
}) |
|
|
|
@app.route("/portal/invoice/<int:invoice_id>/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/<int:invoice_id>", 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"<tr><td>{cat_label}</td><td>{block['totals'][cat_key]:.2f}</td></tr>") |
|
period_cards.append(f""" |
|
<div class="ab-card"> |
|
<h2>{block['label']}</h2> |
|
<div class="ab-grand">{block['grand']:.2f}</div> |
|
<table class="ab-table"> |
|
<tbody> |
|
{''.join(lines)} |
|
</tbody> |
|
</table> |
|
</div> |
|
""") |
|
|
|
html = f"""<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Accountbook - OTB Billing</title> |
|
<link rel="stylesheet" href="/static/css/style.css"> |
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
|
<style> |
|
.wrap {{ max-width: 1400px; margin: 2rem auto; padding: 1rem; }} |
|
.topbar {{ display:flex; justify-content:space-between; gap:1rem; flex-wrap:wrap; align-items:center; margin-bottom:1rem; }} |
|
.actions a {{ margin-right: 1rem; }} |
|
.grid {{ display:grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap:1rem; margin-top:1rem; }} |
|
.ab-card {{ border:1px solid rgba(255,255,255,0.14); border-radius:14px; padding:1rem; background:rgba(255,255,255,0.03); }} |
|
.ab-card h2 {{ margin-top:0; margin-bottom:0.6rem; }} |
|
.ab-grand {{ font-size:2rem; font-weight:800; margin-bottom:0.8rem; }} |
|
.ab-table {{ width:100%; border-collapse:collapse; }} |
|
.ab-table td {{ padding:0.5rem 0.35rem; border-bottom:1px solid rgba(255,255,255,0.10); }} |
|
.ab-table td:last-child {{ text-align:right; font-weight:700; }} |
|
.muted {{ opacity:0.85; }} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="wrap"> |
|
<div class="topbar"> |
|
<div> |
|
<h1>Accountbook</h1> |
|
<p class="muted">Confirmed payment totals by period and payment type.</p> |
|
</div> |
|
<div class="actions"> |
|
<a href="/">Dashboard</a> |
|
<a href="/payments">Payments</a> |
|
<a href="/square/reconciliation">Square Reconciliation</a> |
|
<a href="/accountbook/export.csv">Export CSV</a> |
|
</div> |
|
</div> |
|
|
|
<div class="grid"> |
|
{''.join(period_cards)} |
|
</div> |
|
</div> |
|
</body> |
|
</html>""" |
|
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""" |
|
<div style='display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin:1rem 0 1.25rem 0;'> |
|
<a href="/square/reconciliation?filter=processed" style='border:1px solid rgba(255,255,255,0.14);border-radius:14px;padding:1rem;background:rgba(255,255,255,0.03);text-decoration:none;color:inherit;display:block;'> |
|
<div style='opacity:0.82;'>Processed</div> |
|
<div style='font-size:2rem;font-weight:800;color:#4ade80;'>{summary_cards["processed_true"]}</div> |
|
</a> |
|
<a href="/square/reconciliation?filter=duplicates" style='border:1px solid rgba(255,255,255,0.14);border-radius:14px;padding:1rem;background:rgba(255,255,255,0.03);text-decoration:none;color:inherit;display:block;'> |
|
<div style='opacity:0.82;'>Duplicates</div> |
|
<div style='font-size:2rem;font-weight:800;color:#fbbf24;'>{summary_cards["duplicates"]}</div> |
|
</a> |
|
<a href="/square/reconciliation?filter=failures" style='border:1px solid rgba(255,255,255,0.14);border-radius:14px;padding:1rem;background:rgba(255,255,255,0.03);text-decoration:none;color:inherit;display:block;'> |
|
<div style='opacity:0.82;'>Failures</div> |
|
<div style='font-size:2rem;font-weight:800;color:#f87171;'>{summary_cards["failures"]}</div> |
|
</a> |
|
<a href="/square/reconciliation?filter=invalid" style='border:1px solid rgba(255,255,255,0.14);border-radius:14px;padding:1rem;background:rgba(255,255,255,0.03);text-decoration:none;color:inherit;display:block;'> |
|
<div style='opacity:0.82;'>Invalid Signatures</div> |
|
<div style='font-size:2rem;font-weight:800;color:#cbd5e1;'>{summary_cards["sig_invalid"]}</div> |
|
</a> |
|
</div> |
|
""" |
|
|
|
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""" |
|
<tr> |
|
<td>{logged_at}</td> |
|
<td>{event_type}</td> |
|
<td>{payment_id}</td> |
|
<td>{amount_money}</td> |
|
<td>{note}</td> |
|
<td>{signature_text}</td> |
|
<td class="{result_class}">{result_text}</td> |
|
</tr> |
|
""") |
|
|
|
html = f"""<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Square Reconciliation - OTB Billing</title> |
|
<link rel="stylesheet" href="/static/css/style.css"> |
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
|
<style> |
|
.wrap {{ max-width: 1400px; margin: 2rem auto; padding: 1rem; }} |
|
.topbar {{ display:flex; justify-content:space-between; gap:1rem; flex-wrap:wrap; align-items:center; margin-bottom:1rem; }} |
|
.actions a {{ margin-right: 1rem; }} |
|
table {{ width:100%; border-collapse: collapse; margin-top: 1rem; }} |
|
th, td {{ padding: 0.7rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align:left; vertical-align:top; }} |
|
th {{ background:#e9eef7; color:#10203f; }} |
|
.ok {{ color:#4ade80; font-weight:700; }} |
|
.warn {{ color:#fbbf24; font-weight:700; }} |
|
.muted {{ opacity:0.8; }} |
|
code {{ font-size: 0.92em; }} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="wrap"> |
|
<div class="topbar"> |
|
<div> |
|
<h1>Square Reconciliation</h1> |
|
<p class="muted">Recent Square webhook events and auto-apply outcomes.</p> |
|
</div> |
|
<div class="actions"> |
|
<a href="/">Dashboard</a> |
|
<a href="/payments">Payments</a> |
|
<a href="/invoices">Invoices</a> |
|
</div> |
|
</div> |
|
|
|
<p><strong>Log file:</strong> <code>{SQUARE_WEBHOOK_LOG}</code></p> |
|
<p><strong>Current Filter:</strong> <code>{filter_mode or "all"}</code></p> |
|
|
|
{summary_html} |
|
|
|
<table> |
|
<thead> |
|
<tr> |
|
<th>Logged At (UTC)</th> |
|
<th>Event</th> |
|
<th>Payment ID</th> |
|
<th>Amount (cents)</th> |
|
<th>Note</th> |
|
<th>Sig Valid</th> |
|
<th>Auto Apply Result</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
{''.join(rows_html) if rows_html else '<tr><td colspan="7">No webhook events found.</td></tr>'} |
|
</tbody> |
|
</table> |
|
</div> |
|
</body> |
|
</html>""" |
|
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)
|
|
|