billing frontend for mariadb. setup as otb_billing for outsidethebox.top accounting. also involved with outsidethedb
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.
 
 
 
 

332 lines
11 KiB

=== FILE LIST ===
/home/def/otb_billing/backend/app.py
/home/def/otb_billing/static/css/style.css
/home/def/otb_billing/templates/base.html
/home/def/otb_billing/templates/clients/edit.html
/home/def/otb_billing/templates/clients/list.html
/home/def/otb_billing/templates/clients/new.html
/home/def/otb_billing/templates/credits/add.html
/home/def/otb_billing/templates/credits/list.html
/home/def/otb_billing/templates/dashboard.html
/home/def/otb_billing/templates/footer.html
/home/def/otb_billing/templates/health.html
/home/def/otb_billing/templates/invoices/edit.html
/home/def/otb_billing/templates/invoices/list.html
/home/def/otb_billing/templates/invoices/new.html
/home/def/otb_billing/templates/invoices/print_batch.html
/home/def/otb_billing/templates/invoices/view.html
/home/def/otb_billing/templates/payments/edit.html
/home/def/otb_billing/templates/payments/list.html
/home/def/otb_billing/templates/payments/new.html
/home/def/otb_billing/templates/portal_dashboard.html
/home/def/otb_billing/templates/portal_forgot_password.html
/home/def/otb_billing/templates/portal_invoice_detail.html
/home/def/otb_billing/templates/portal_login.html
/home/def/otb_billing/templates/portal_set_password.html
/home/def/otb_billing/templates/reports/aging.html
/home/def/otb_billing/templates/reports/revenue.html
/home/def/otb_billing/templates/reports/revenue_print.html
/home/def/otb_billing/templates/services/edit.html
/home/def/otb_billing/templates/services/list.html
/home/def/otb_billing/templates/services/new.html
/home/def/otb_billing/templates/settings.html
/home/def/otb_billing/templates/subscriptions/list.html
/home/def/otb_billing/templates/subscriptions/new.html
=== APP.PY ===
import os
from flask import Flask, render_template, request, redirect, send_file, make_response, jsonify, session, Response
from db import get_db_connection
from utils import generate_client_code, generate_service_code
from datetime import datetime, timezone, date, timedelta
from zoneinfo import ZoneInfo
from decimal import Decimal, InvalidOperation
from pathlib import Path
from email.message import EmailMessage
from dateutil.relativedelta import relativedelta
from io import BytesIO, StringIO
import csv
import json
import hmac
import hashlib
import base64
import urllib.request
import urllib.error
import urllib.parse
import uuid
import re
import math
import zipfile
import smtplib
import secrets
import threading
import time
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from werkzeug.security import generate_password_hash, check_password_hash
from health import register_health_routes
app = Flask(
__name__,
template_folder="../templates",
static_folder="../static",
)
app.config["OTB_HEALTH_DB_CONNECTOR"] = get_db_connection
LOCAL_TZ = ZoneInfo("America/Toronto")
BASE_DIR = Path(__file__).resolve().parent.parent
app.secret_key = os.getenv("OTB_BILLING_SECRET_KEY", "otb-billing-dev-secret-change-me")
SQUARE_ACCESS_TOKEN = os.getenv("SQUARE_ACCESS_TOKEN", "")
SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "")
SQUARE_WEBHOOK_NOTIFICATION_URL = os.getenv("SQUARE_WEBHOOK_NOTIFICATION_URL", "")
SQUARE_API_BASE = "https://connect.squareup.com"
SQUARE_API_VERSION = "2026-01-22"
SQUARE_WEBHOOK_LOG = str(BASE_DIR / "logs" / "square_webhook_events.log")
ORACLE_BASE_URL = os.getenv("ORACLE_BASE_URL", "https://monitor.outsidethebox.top")
CRYPTO_EVM_PAYMENT_ADDRESS = os.getenv("OTB_BILLING_CRYPTO_EVM_ADDRESS", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5")
RPC_ETHEREUM_URL = os.getenv("OTB_BILLING_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com")
RPC_ETHEREUM_URL_2 = os.getenv("OTB_BILLING_RPC_ETHEREUM_2", "https://rpc.ankr.com/eth")
RPC_ETHEREUM_URL_3 = os.getenv("OTB_BILLING_RPC_ETHEREUM_3", "https://eth.drpc.org")
RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com")
RPC_ARBITRUM_URL_2 = os.getenv("OTB_BILLING_RPC_ARBITRUM_2", "https://rpc.ankr.com/arbitrum")
RPC_ARBITRUM_URL_3 = os.getenv("OTB_BILLING_RPC_ARBITRUM_3", "https://arb1.arbitrum.io/rpc")
RPC_ETICA_URL = os.getenv("OTB_BILLING_RPC_ETICA", "https://rpc.etica-stats.org")
RPC_ETICA_URL_2 = os.getenv("OTB_BILLING_RPC_ETICA_2", "https://eticamainnet.eticaprotocol.org")
RPC_ETHO_URL = os.getenv("OTB_BILLING_RPC_ETHO", "https://rpc.ethoprotocol.com")
RPC_ETHO_URL_2 = os.getenv("OTB_BILLING_RPC_ETHO_2", "https://rpc4.ethoprotocol.com")
CRYPTO_PROCESSING_TIMEOUT_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS", "180"))
CRYPTO_WATCH_INTERVAL_SECONDS = int(os.getenv("OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS", "30"))
CRYPTO_WATCHER_STARTED = False
def load_version():
try:
with open(BASE_DIR / "VERSION", "r") as f:
return f.read().strip()
except Exception:
return "unknown"
APP_VERSION = load_version()
@app.context_processor
def inject_version():
return {"app_version": APP_VERSION}
@app.context_processor
def inject_app_settings():
return {"app_settings": get_app_settings()}
def fmt_local(dt_value):
if not dt_value:
return ""
if isinstance(dt_value, str):
try:
dt_value = datetime.fromisoformat(dt_value)
except ValueError:
return str(dt_value)
if dt_value.tzinfo is None:
dt_value = dt_value.replace(tzinfo=timezone.utc)
return dt_value.astimezone(LOCAL_TZ).strftime("%Y-%m-%d %I:%M:%S %p")
def to_decimal(value):
if value is None or value == "":
return Decimal("0")
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return Decimal("0")
def fmt_money(value, currency_code="CAD"):
amount = to_decimal(value)
if currency_code == "CAD":
return f"{amount:.2f}"
return f"{amount:.8f}"
def payment_method_label(method, currency=None):
method_key = str(method or "").strip().lower()
currency_key = str(currency or "").strip().upper()
if method_key == "square":
return "Square"
if method_key == "etransfer":
return "e-Transfer"
if method_key == "cash":
return "Cash"
if method_key == "other":
if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}:
return currency_key
return "Other"
if method_key == "crypto_etho":
return "ETHO"
if method_key == "crypto_egaz":
return "EGAZ"
if method_key == "crypto_alt":
return "ALT"
if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT"}:
return currency_key
return method or "Unknown"
def get_invoice_payments(invoice_id):
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
id,
payment_method,
payment_currency,
payment_amount,
cad_value_at_payment,
reference,
sender_name,
txid,
wallet_address,
payment_status,
confirmations,
confirmation_required,
received_at,
created_at,
notes
FROM payments
WHERE invoice_id = %s
ORDER BY COALESCE(received_at, created_at) ASC, id ASC
""", (invoice_id,))
rows = cursor.fetchall()
conn.close()
out = []
for row in rows:
item = dict(row)
item["payment_method_label"] = payment_method_label(
item.get("payment_method"),
item.get("payment_currency"),
)
item["payment_amount_display"] = fmt_money(
item.get("payment_amount"),
item.get("payment_currency") or "CAD",
)
item["cad_value_display"] = fmt_money(item.get("cad_value_at_payment"), "CAD")
item["received_at_local"] = fmt_local(item.get("received_at") or item.get("created_at"))
out.append(item)
return out
def normalize_oracle_datetime(value):
if not value:
return None
try:
text = str(value).replace("Z", "+00:00")
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return None
def ensure_invoice_quote_columns():
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'invoices'
""")
existing = {row["COLUMN_NAME"] for row in cursor.fetchall()}
wanted = {
"quote_fiat_amount": "ALTER TABLE invoices ADD COLUMN quote_fiat_amount DECIMAL(18,8) DEFAULT NULL AFTER status",
"quote_fiat_currency": "ALTER TABLE invoices ADD COLUMN quote_fiat_currency VARCHAR(16) DEFAULT NULL AFTER quote_fiat_amount",
"quote_expires_at": "ALTER TABLE invoices ADD COLUMN quote_expires_at DATETIME DEFAULT NULL AFTER quote_fiat_currency",
"oracle_snapshot": "ALTER TABLE invoices ADD COLUMN oracle_snapshot LONGTEXT DEFAULT NULL AFTER quote_expires_at"
}
exec_cursor = conn.cursor()
changed = False
for column_name, ddl in wanted.items():
if column_name not in existing:
exec_cursor.execute(ddl)
changed = True
if changed:
conn.commit()
conn.close()
def fetch_oracle_quote_snapshot(currency_code, total_amount):
if str(currency_code or "").upper() != "CAD":
return None
try:
amount_value = Decimal(str(total_amount))
if amount_value <= 0:
return None
except (InvalidOperation, ValueError):
return None
try:
qs = urllib.parse.urlencode({
"fiat": "CAD",
"amount": format(amount_value, "f"),
})
req = urllib.request.Request(
f"{ORACLE_BASE_URL.rstrip('/')}/api/oracle/quote?{qs}",
headers={
"Accept": "application/json",
"User-Agent": "otb-billing-oracle/0.1"
},
method="GET"
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not isinstance(data, dict) or not isinstance(data.get("quotes"), list):
return None
return {
"oracle_url": ORACLE_BASE_URL.rstrip("/"),
=== templates/base.html ===
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{ page_title }}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<header>
<h1>OTB Billing</h1>
</header>
<main>
<p>{{ content }}</p>
</main>
</body>
</html>
{% include "footer.html" %}
=== /home/def/otb_billing/static/css/style.css ===
body {
font-family: Arial;
background: #0f172a;
color: #e5e7eb;
}
header {
padding: 20px;
background: #111827;
}