10 changed files with 375 additions and 352 deletions
@ -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. |
||||
|
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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> |
||||
Loading…
Reference in new issue