diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 375c259..24df263 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,350 +1,32 @@ -# OTB Billing — Project State - -Last Updated: 2026-03-09 -Version: v0.3.1 -Project Path: ~/otb_billing - ---- - -# Project Purpose - -OTB Billing is a contractor-focused billing system designed to be: - -- self-hosted -- portable -- database-backed -- deployable on fresh Linux systems -- suitable for managed hosting or client-installed deployments - -The system is being built as a practical alternative to restrictive SaaS billing tools, with emphasis on ownership, simplicity, portability, and contractor/operator workflow. - -Tagline direction: - -By a contractor, for contractors - ---- - -# Current Stack - -Backend: -Flask - -Database: -MariaDB - -PDF Engine: -ReportLab - -Primary Port: -5050 - -Dependencies file: -requirements.txt - ---- - -# Deployment Philosophy - -OTB Billing must remain a deployable product, not just a dev-only app. - -Target install model: - -fresh server -→ installer runs -→ dependencies install -→ MariaDB setup -→ schema setup -→ app launches - -This remains a core project rule. - ---- - -# Current Core Features - -## Clients -- create client -- edit client -- list clients -- status field -- client code support - -## Services -- create service -- edit service -- list services -- service code support -- service status support - -## Invoices -- create invoice -- edit invoice -- list invoices -- automatic invoice numbering -- invoice print view -- invoice PDF download -- invoice lock after payment activity -- invoice statuses -- invoice email sending with PDF attachment - -Current invoice statuses: -- draft -- pending -- partial -- paid -- overdue -- cancelled - -## Payments -- record payment -- edit payment -- list payments -- overpayment guard on new payment -- overpayment guard on payment edit -- payment status display -- payment void / reversal workflow -- invoice recalculation after payment changes - -Current payment statuses: -- confirmed -- reversed - -## Credit Ledger -- client credit ledger -- manual credit entries -- client balance color coding -- ledger link visible from client list/edit pages - -## Invoice Rendering -- HTML invoice view -- print-friendly layout -- PDF invoice generation -- client details on invoice -- status badge on invoice -- totals / paid / remaining display -- branding/logo support on HTML and PDF - -## Exports -- clients CSV export -- invoices CSV export -- payments CSV export -- filtered invoice CSV export -- filtered invoice PDF ZIP export -- accounting package ZIP export -- revenue report JSON export - -## Batch / Print -- filtered batch invoice print page -- print-friendly revenue report - -## Reports -- revenue report -- report frequency selector -- JSON report export -- email revenue report JSON - -## Settings / Configuration System -Accessible from: -/settings - -Stored in database table: -app_settings - -### Business Identity Settings -- business name -- business tagline -- business logo URL -- business email -- business phone -- business address -- business website -- business registration number - -### Tax Settings -- tax label -- tax rate -- tax number -- local country -- apply local tax only flag - -### Invoice Behavior Settings -- default currency -- invoice footer -- payment terms -- report frequency - -### SMTP / Email Settings -- SMTP host -- SMTP port -- SMTP username -- SMTP password -- SMTP from email -- SMTP from name -- TLS flag -- SSL flag -- report delivery email - -## Email Delivery -- invoice email with PDF attachment -- revenue report JSON email -- accounting package email - -## Email Logging -Stored in: -email_log - -Tracks: -- email_type -- invoice_id -- recipient_email -- subject -- status -- error_message -- sent_at - -Currently logs: -- invoice email sends -- revenue report email sends -- accounting package email sends - ---- - -# Current Known Good State - -Confirmed working: -- dashboard -- clients -- services -- invoice creation -- auto invoice numbering -- invoice view -- invoice PDF generation -- invoice email with PDF attachment -- payment entry -- payment overpayment prevention -- payment reversal / void -- payments list with invoice status and remaining balance -- settings/config page -- business identity shown on invoice view/PDF -- logo display in HTML and PDF -- clients/invoices/payments CSV export -- filtered invoice export -- filtered invoice PDF ZIP export -- batch invoice print -- revenue report -- revenue report JSON export -- revenue report email -- accounting package ZIP export -- accounting package email -- email audit logging - ---- - -# Requirements - -Current requirements.txt should include: -- Flask -- mysql-connector-python -- reportlab -- python-dateutil -- pytz - -This file must remain complete so installer-driven deployment works in one shot. - ---- - -# Business / Product Direction - -This system is intended to grow into a deployable billing product for small operators, hosting providers, and service businesses. - -Target strengths versus typical SaaS billing tools: -- simpler workflow -- data ownership -- exportability -- portability -- operator-first design -- no hostage-style software design - -Long-term success goal: -build something users are happy to use and proud to own. - ---- - -# Planned Next Features - -## Near-Term -- invoice defaults from settings -- improved tax application logic -- accountant package scheduling / reminders -- client account statement export -- backup/install polish - -## Medium-Term -- quote / estimate system -- recurring invoices -- reminder workflows -- better installer/update flow -- email resend history view - -## Long-Term -- client portal -- role-based access -- accountant/export workflows -- integration paths for vertical forks such as HVAC/customer-service variants - ---- - -# Advanced Settings Direction - -Business identity and SMTP belong in settings UI. - -Database credentials should remain installer/config-file driven, not casually editable in standard UI. - -If advanced connection settings are ever exposed in UI, they must be clearly marked as dangerous / advanced and should avoid redisplaying stored passwords. - ---- - -# Repository Discipline - -For this project going forward: -- keep PROJECT_STATE.md updated -- update README.md with version/build notes -- keep requirements.txt complete -- make full ZIP backup on version bumps -- push milestones to git - -Example future archive naming: -otb_billing-v0.3.1.zip - ---- - -# Restart / Run Notes - -Development run method: - -cd ~/otb_billing -python3 backend/app.py - -During active development, run in a visible terminal so logs stay visible. - -Do not rely on hidden/background launch during normal debug workflow. - -================================================= -Version: v0.4.0-dev -Date: 2026-03-10 -================================================= - -Recurring billing foundation added. - -New recurring billing capabilities: -- subscriptions table auto-created by app -- subscriptions list page -- new subscription creation page -- billing interval support: monthly / quarterly / yearly -- manual recurring billing run button -- automatic invoice creation for due subscriptions -- next_invoice_date auto-advances after invoice creation - -Notes: -- first recurring billing slice is manual-run based -- cron/systemd timer automation can be added next +Project: OTB Billing +Version: v0.4.0 +Last Updated: 2026-03-12 +Status: Stable release checkpoint + +Current State: +- Flask app runs under systemd as otb_billing.service. +- Service starts through /home/def/otb_billing/run_dev.sh. +- Runtime environment is loaded from /home/def/otb_billing/.env by the shell wrapper. +- App listens on 0.0.0.0:5050 for mintme webfront proxy access. +- /health renders as a styled page. +- /health.json provides raw machine-readable health data. +- Aging report is visually acceptable and readable. +- Reboot persistence confirmed. + +Important Paths: +- Project root: /home/def/otb_billing +- App entry: /home/def/otb_billing/backend/app.py +- Health module: /home/def/otb_billing/backend/health.py +- Runtime wrapper: /home/def/otb_billing/run_dev.sh +- Env file: /home/def/otb_billing/.env +- Service unit: /etc/systemd/system/otb_billing.service +- Repo copy of unit: /home/def/otb_billing/deploy/systemd/otb_billing.service + +Operations: +- sudo systemctl status otb_billing +- sudo systemctl restart otb_billing +- sudo journalctl -u otb_billing -f + +Release Notes: +- This version is considered a finished product suitable for normal use. +- Further work, if any, should be touch-ups and enhancements rather than core stabilization. diff --git a/README.md b/README.md index bec91ad..111eeaf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ +- System package dependency note: install `zip` on deployment hosts for release snapshot creation. +## v0.4.0 - 2026-03-12 +- Released stable service-managed build of OTB Billing. +- Added working /health styled page and /health.json endpoint. +- Added health backend module for app/server/db/disk/memory status reporting. +- Added .env-based runtime configuration through run_dev.sh shell loading. +- Moved runtime control to systemd via otb_billing.service. +- Confirmed reboot persistence and mintme webfront reachability through LAN bind on port 5050. +- Kept debug off and reloader disabled for stable service operation. + # otb-billing ## v0.3.0 — 2026-03-09 diff --git a/VERSION b/VERSION index ef9b4e2..fb7a04c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4.0-dev +v0.4.0 diff --git a/backend/app.py b/backend/app.py index 7acf5fa..9981a0a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -15,12 +15,14 @@ import smtplib from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader +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") @@ -2970,5 +2972,6 @@ def edit_payment(payment_id): conn.close() return render_template("payments/edit.html", payment=payment, errors=[]) +register_health_routes(app) if __name__ == "__main__": - app.run(host="0.0.0.0", port=5050, debug=True) + app.run(host="0.0.0.0", port=5050, debug=False, use_reloader=False) diff --git a/backend/health.py b/backend/health.py new file mode 100644 index 0000000..7b1ee41 --- /dev/null +++ b/backend/health.py @@ -0,0 +1,137 @@ +import os +import time +import shutil +import platform +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from flask import render_template, jsonify + +APP_START_TS = time.time() + + +def _read_meminfo(): + data = {} + try: + with open("/proc/meminfo", "r", encoding="utf-8") as f: + for line in f: + if ":" not in line: + continue + key, val = line.split(":", 1) + data[key.strip()] = val.strip() + except Exception: + pass + return data + + +def _kb_to_mb(kb_value): + try: + return round(int(kb_value) / 1024, 2) + except Exception: + return None + + +def _server_uptime_seconds(): + try: + with open("/proc/uptime", "r", encoding="utf-8") as f: + return int(float(f.read().split()[0])) + except Exception: + return None + + +def _format_duration(seconds): + if seconds is None: + return None + seconds = int(seconds) + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, secs = divmod(rem, 60) + return f"{days}d {hours}h {minutes}m {secs}s" + + +def _health_payload(app): + now_utc = datetime.now(timezone.utc) + now_toronto = now_utc.astimezone(ZoneInfo("America/Toronto")) + + load1 = load5 = load15 = None + try: + load1, load5, load15 = os.getloadavg() + except Exception: + pass + + meminfo = _read_meminfo() + mem_total_kb = None + mem_available_kb = None + mem_used_kb = None + mem_used_percent = None + try: + mem_total_kb = int(meminfo.get("MemTotal", "0 kB").split()[0]) + mem_available_kb = int(meminfo.get("MemAvailable", "0 kB").split()[0]) + mem_used_kb = mem_total_kb - mem_available_kb + if mem_total_kb > 0: + mem_used_percent = round((mem_used_kb / mem_total_kb) * 100, 2) + except Exception: + pass + + disk = shutil.disk_usage("/") + + db_ok = False + db_error = None + try: + connector = app.config.get("OTB_HEALTH_DB_CONNECTOR") + if callable(connector): + conn = connector() + cur = conn.cursor() + cur.execute("SELECT 1") + cur.fetchone() + cur.close() + conn.close() + db_ok = True + else: + db_error = "DB connector not registered" + except Exception as e: + db_error = str(e) + + app_uptime = int(time.time() - APP_START_TS) + server_uptime = _server_uptime_seconds() + + payload = { + "status": "ok" if db_ok else "degraded", + "app_name": "otb_billing", + "hostname": platform.node(), + "server_time_utc": now_utc.isoformat(), + "server_time_toronto": now_toronto.isoformat(), + "app_uptime_seconds": app_uptime, + "app_uptime_human": _format_duration(app_uptime), + "server_uptime_seconds": server_uptime, + "server_uptime_human": _format_duration(server_uptime), + "load_average": {"1m": load1, "5m": load5, "15m": load15}, + "memory": { + "total_mb": _kb_to_mb(mem_total_kb) if mem_total_kb is not None else None, + "available_mb": _kb_to_mb(mem_available_kb) if mem_available_kb is not None else None, + "used_mb": _kb_to_mb(mem_used_kb) if mem_used_kb is not None else None, + "used_percent": mem_used_percent, + }, + "disk_root": { + "total_gb": round(disk.total / (1024**3), 2), + "used_gb": round(disk.used / (1024**3), 2), + "free_gb": round(disk.free / (1024**3), 2), + "used_percent": round((disk.used / disk.total) * 100, 2) if disk.total else None, + }, + "database": {"ok": db_ok, "error": db_error}, + } + + http_code = 200 if db_ok else 503 + return payload, http_code + + +def register_health_routes(app): + @app.route("/health", methods=["GET"]) + def health_page(): + health, http_code = _health_payload(app) + return render_template("health.html", health=health), http_code + + @app.route("/health.json", methods=["GET"]) + def health_json(): + health, http_code = _health_payload(app) + return jsonify(health), http_code diff --git a/deploy/systemd/otb_billing.service b/deploy/systemd/otb_billing.service new file mode 100644 index 0000000..3d7978c --- /dev/null +++ b/deploy/systemd/otb_billing.service @@ -0,0 +1,17 @@ +[Unit] +Description=OTB Billing Flask App +After=network.target mariadb.service +Wants=network.target + +[Service] +Type=simple +User=def +Group=def +WorkingDirectory=/home/def/otb_billing +ExecStart=/home/def/otb_billing/run_dev.sh +Restart=always +RestartSec=3 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/install.sh b/install.sh index a15eb51..843344b 100755 --- a/install.sh +++ b/install.sh @@ -1,3 +1,4 @@ +# NOTE: system package dependency: zip #!/usr/bin/env bash set -euo pipefail diff --git a/run_dev.sh b/run_dev.sh new file mode 100755 index 0000000..a099f18 --- /dev/null +++ b/run_dev.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /home/def/otb_billing + +if [ -f .env ]; then + set -a + . ./.env + set +a +fi + +exec python3 backend/app.py diff --git a/templates/health.html b/templates/health.html new file mode 100644 index 0000000..a2168df --- /dev/null +++ b/templates/health.html @@ -0,0 +1,139 @@ + + + + + + System Health - OTB Billing + + + + +
+ + + + +
+
+

Status

+ {% if health.status == "ok" %} +

Healthy

+ {% else %} +

Degraded

+ {% endif %} +

App: {{ health.app_name }}

+

Host: {{ health.hostname }}

+

Toronto Time: {{ health.server_time_toronto }}

+

UTC Time: {{ health.server_time_utc }}

+
+ +
+

Database

+ {% if health.database.ok %} +

Connected

+ {% else %} +

Connection Error

+ {% endif %} +

Error: {{ health.database.error or "None" }}

+
+ +
+

Uptime

+

Application: {{ health.app_uptime_human }}

+

Server: {{ health.server_uptime_human }}

+
+ +
+

Load Average

+

1 min: {{ health.load_average["1m"] }}

+

5 min: {{ health.load_average["5m"] }}

+

15 min: {{ health.load_average["15m"] }}

+
+ +
+

Memory

+

Total: {{ health.memory.total_mb }} MB

+

Available: {{ health.memory.available_mb }} MB

+

Used: {{ health.memory.used_mb }} MB

+

Used %: {{ health.memory.used_percent }}%

+
+ +
+

Disk /

+

Total: {{ health.disk_root.total_gb }} GB

+

Used: {{ health.disk_root.used_gb }} GB

+

Free: {{ health.disk_root.free_gb }} GB

+

Used %: {{ health.disk_root.used_percent }}%

+
+
+
+ +{% include "footer.html" %} + + diff --git a/templates/reports/aging.html b/templates/reports/aging.html index eccdbef..b395724 100644 --- a/templates/reports/aging.html +++ b/templates/reports/aging.html @@ -1,3 +1,25 @@ + + +