Browse Source

Release v0.4.0: stable health page, systemd service, env-based runtime

main v0.4.0
def 1 week ago
parent
commit
48102325e3
  1. 382
      PROJECT_STATE.md
  2. 10
      README.md
  3. 2
      VERSION
  4. 5
      backend/app.py
  5. 137
      backend/health.py
  6. 17
      deploy/systemd/otb_billing.service
  7. 1
      install.sh
  8. 12
      run_dev.sh
  9. 139
      templates/health.html
  10. 22
      templates/reports/aging.html

382
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.

10
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

2
VERSION

@ -1 +1 @@
v0.4.0-dev
v0.4.0

5
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)

137
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

17
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

1
install.sh

@ -1,3 +1,4 @@
# NOTE: system package dependency: zip
#!/usr/bin/env bash
set -euo pipefail

12
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

139
templates/health.html

@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Health - OTB Billing</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.page-wrap {
padding: 1.5rem;
}
.page-header h1 {
margin-bottom: 0.35rem;
}
.page-header p {
margin-top: 0;
opacity: 0.9;
}
.health-links {
margin: 1rem 0 1.25rem 0;
}
.health-links a {
margin-right: 1rem;
text-decoration: underline;
}
.health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.health-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
padding: 1rem 1.1rem;
background: rgba(255,255,255,0.03);
box-shadow: 0 6px 18px rgba(0,0,0,0.16);
}
.health-card h3 {
margin-top: 0;
margin-bottom: 0.85rem;
}
.health-card p {
margin: 0.45rem 0;
}
.status-good {
color: #63d471;
font-weight: 700;
}
.status-bad {
color: #ff7b7b;
font-weight: 700;
}
.monoish {
word-break: break-word;
}
</style>
</head>
<body>
<div class="page-wrap">
<div class="page-header">
<h1>System Health</h1>
<p>Application and server status for OTB Billing.</p>
</div>
<div class="health-links">
<a href="/">Home</a>
<a href="/health.json">Raw JSON</a>
</div>
<div class="health-grid">
<div class="health-card">
<h3>Status</h3>
{% if health.status == "ok" %}
<p class="status-good">Healthy</p>
{% else %}
<p class="status-bad">Degraded</p>
{% endif %}
<p><strong>App:</strong> {{ health.app_name }}</p>
<p><strong>Host:</strong> {{ health.hostname }}</p>
<p class="monoish"><strong>Toronto Time:</strong> {{ health.server_time_toronto }}</p>
<p class="monoish"><strong>UTC Time:</strong> {{ health.server_time_utc }}</p>
</div>
<div class="health-card">
<h3>Database</h3>
{% if health.database.ok %}
<p class="status-good">Connected</p>
{% else %}
<p class="status-bad">Connection Error</p>
{% endif %}
<p class="monoish"><strong>Error:</strong> {{ health.database.error or "None" }}</p>
</div>
<div class="health-card">
<h3>Uptime</h3>
<p><strong>Application:</strong> {{ health.app_uptime_human }}</p>
<p><strong>Server:</strong> {{ health.server_uptime_human }}</p>
</div>
<div class="health-card">
<h3>Load Average</h3>
<p><strong>1 min:</strong> {{ health.load_average["1m"] }}</p>
<p><strong>5 min:</strong> {{ health.load_average["5m"] }}</p>
<p><strong>15 min:</strong> {{ health.load_average["15m"] }}</p>
</div>
<div class="health-card">
<h3>Memory</h3>
<p><strong>Total:</strong> {{ health.memory.total_mb }} MB</p>
<p><strong>Available:</strong> {{ health.memory.available_mb }} MB</p>
<p><strong>Used:</strong> {{ health.memory.used_mb }} MB</p>
<p><strong>Used %:</strong> {{ health.memory.used_percent }}%</p>
</div>
<div class="health-card">
<h3>Disk /</h3>
<p><strong>Total:</strong> {{ health.disk_root.total_gb }} GB</p>
<p><strong>Used:</strong> {{ health.disk_root.used_gb }} GB</p>
<p><strong>Free:</strong> {{ health.disk_root.free_gb }} GB</p>
<p><strong>Used %:</strong> {{ health.disk_root.used_percent }}%</p>
</div>
</div>
</div>
{% include "footer.html" %}
</body>
</html>

22
templates/reports/aging.html

@ -1,3 +1,25 @@
<!-- OTB_AGING_CONTRAST_FIX_START -->
<style>
table thead th,
table tr:first-child th {
color: #10203f !important;
background: #e9eef7 !important;
font-weight: 700 !important;
}
table tbody td {
color: #f5f7fb !important;
}
table tbody td a {
color: #ffffff !important;
}
table tbody tr:last-child td,
table tfoot td {
color: #10203f !important;
background: #e9eef7 !important;
font-weight: 700 !important;
}
</style>
<!-- OTB_AGING_CONTRAST_FIX_END -->
<!doctype html>
<html>
<head>

Loading…
Cancel
Save