Browse Source

Update PROJECT_STATE and capture current billing system state

main
def 2 weeks ago
parent
commit
2363b271aa
  1. 339
      PROJECT_STATE.md
  2. 517
      backend/app.py
  3. 5
      requirements.txt
  4. 3
      templates/dashboard.html
  5. 58
      templates/invoices/edit.html
  6. 30
      templates/invoices/list.html
  7. 6
      templates/invoices/new.html
  8. 202
      templates/invoices/view.html
  9. 34
      templates/payments/edit.html
  10. 62
      templates/payments/list.html
  11. 41
      templates/payments/new.html
  12. 169
      templates/settings.html

339
PROJECT_STATE.md

@ -1,53 +1,96 @@
# OTB Billing Project State # OTB Billing Project State
Version: v0.3-dev Last Updated: 2026-03-09
Status: Active Development Version: v0.3-dev
Backend: Flask (Python) Project Path: ~/otb_billing
Database: MariaDB
Default Port: 5050
Install Location: ~/otb_billing
--- ---
# Overview # Project Purpose
OTB Billing is a lightweight contractor-focused billing system designed to be: OTB Billing is a contractor-focused billing system designed to be:
- self-hostable - self-hosted
- deployable via installer - portable
- database-driven - database-backed
- portable across Linux servers - deployable on fresh Linux systems
- simple HTML frontend with strong backend logic - suitable for managed hosting or client-installed deployments
The system is intended to support both: The system is being built as a practical alternative to overly restrictive SaaS billing tools, with emphasis on ownership, simplicity, and contractor workflow.
1) client self-hosting installs Tagline direction:
2) managed hosted deployments under outsidethebox.top
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 # Current Core Features
## Client Management ## Clients
- create/edit clients
- client code system - create client
- client contact details - edit client
- client ledger link - list clients
- status field
- client code support
## Services ## Services
- service code system
- service descriptions - create service
- reusable services for invoices - edit service
- list services
- service code support
- service status support
## Invoices ## Invoices
- automatic invoice numbering (INV-####)
- invoice creation
- invoice editing
- invoice locking when payments exist
- issued / due dates
- invoice status system
Statuses: - create invoice
- edit invoice
- list invoices
- automatic invoice numbering
- invoice print view
- invoice PDF download
- invoice lock after payment activity
- invoice statuses
Current invoice statuses:
- draft - draft
- pending - pending
- partial - partial
@ -56,186 +99,202 @@ Statuses:
- cancelled - cancelled
## Payments ## Payments
- record payments
- payment confirmation
- payment reversal
- invoice payment tracking
- remaining balance calculation
## Ledger - 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 - client credit ledger
- positive / zero / negative balance color indicators
- manual credit entries - manual credit entries
- client balance color coding
- ledger link visible from client list/edit pages
## Invoice Rendering ## Invoice Rendering
- HTML invoice view - HTML invoice view
- printer friendly layout - print-friendly layout
- invoice totals / remaining balance display - PDF invoice generation
- payment status badges - client details on invoice
- status badge on invoice
- totals, paid, remaining display
## PDF Invoices ---
- PDF generation via ReportLab
- downloadable invoices
- branded invoice header
- tax number support
- payment terms and footer support
## Settings / Configuration System # Current Settings / Config System
Database-stored settings accessible at:
Accessible from:
/settings /settings
Configurable fields: Stored in database table:
app_settings
## Business Identity Settings
Business Identity
- business name - business name
- slogan / tagline - business tagline
- business email - business email
- business phone - business phone
- business address - business address
- website - business website
- business registration number - business registration number
Tax Settings ## Tax Settings
- tax label - tax label
- tax rate - tax rate
- tax number - tax number
- local country - local country
- apply tax only to local clients - apply local tax only flag
## Invoice Behavior Settings
Invoice Behavior - default currency
- invoice footer - invoice footer
- payment terms - payment terms
- default currency
SMTP / Email (prepared for future email invoices) ## SMTP / Email Settings
- SMTP host - SMTP host
- SMTP port - SMTP port
- SMTP username - SMTP username
- SMTP password - SMTP password
- from email - SMTP from email
- from name - SMTP from name
- TLS / SSL flags - TLS flag
- SSL flag
Email sending is not yet wired, but config storage is now in place.
--- ---
# Architecture # Current Known Good State
Backend: Confirmed working:
Flask
Database: - dashboard
MariaDB using mysql-connector-python - clients
- services
- invoice creation
- auto invoice numbering
- invoice view
- invoice PDF generation
- 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
PDF Engine: ---
ReportLab
Dependencies defined in: # Requirements
requirements.txt Current requirements.txt should include:
Flask template system used for UI. - Flask
- mysql-connector-python
- reportlab
- python-dateutil
- pytz
--- This file must remain complete so installer-driven deployment works in one shot.
# Installer Philosophy
OTB Billing is designed to support automated installs. ---
Target workflow: # Business / Product Direction
fresh server This system is intended to grow into a deployable billing product for small contractors and related service businesses.
→ run installer
→ install python dependencies
→ install MariaDB
→ create schema
→ launch application
The goal is a **single guided install path** so non-expert Linux users can deploy it. Target strengths versus typical SaaS billing tools:
README.md will include: - simpler workflow
- data ownership
- exportability
- portability
- contractor-first design
- no hostage-style software design
- config file explanation Long-term success goal:
- installer instructions build something users are happy to use and proud to own.
- deployment examples
--- ---
# Security Considerations # Planned Next Features
Advanced settings such as database credentials should remain outside the main UI and be configured through installer or config file. ## Near-Term
SMTP credentials are stored in the settings table but are not yet used for sending email. - email invoice sending using stored SMTP settings
- branding/logo support
- invoice defaults from settings
- improved tax application logic
- reports/export tools
- batch invoice export / print
--- ## Medium-Term
# Planned Features
## Near Term - quote / estimate system
- recurring invoices
- reminder workflows
- improved branding/theme polish
- better installer/update flow
Email invoices ## Long-Term
- attach generated PDF
- send via configured SMTP
Invoice defaults
- default currency
- default tax rules
Business branding - client portal
- configurable business logo - role-based access
- invoice header logo - accountant/export workflows
- job-tracking integration with related contractor platform modules
Reports ---
- outstanding invoices
- revenue summaries
- client account statements
## Medium Term # Advanced Settings Direction
Quotes / estimates Business identity and SMTP belong in settings UI.
Recurring invoices
Invoice reminders
Client portal
## Long Term Database credentials should remain installer/config-file driven, not casually editable in standard UI.
Multi-currency handling If advanced connection settings are ever exposed in UI, they must be clearly marked as dangerous / advanced and should avoid redisplaying stored passwords.
Exchange rate support
Accounting export (CSV / QuickBooks style)
API access
User authentication system
Role permissions
--- ---
# Deployment Goals # Repository Discipline
OTB Billing should support: For this project going forward:
Self-host installs - keep PROJECT_STATE.md updated
Managed hosting deployments - update README.md with version/build notes
Multi-client environments - keep requirements.txt complete
- make full ZIP backup on version bumps
- push milestones to git
Target integration with: Example future archive naming:
outsidethebox.top hosting services. otb_billing-v0.3.0.zip
--- ---
# Repository Discipline # Restart / Run Notes
Every version bump must:
- update PROJECT_STATE.md
- update README.md changelog
- produce full version snapshot ZIP backup
Example: Development run method:
otb_billing-v0.3.0.zip cd ~/otb_billing
python3 backend/app.py
This ensures reliable rollback and historical tracking. During active development, run in a visible terminal so logs stay visible.
--- Do not rely on hidden/background launch during normal debug workflow.
End of file.

517
backend/app.py

@ -1,10 +1,14 @@
from flask import Flask, render_template, request, redirect from flask import Flask, render_template, request, redirect, send_file
from db import get_db_connection from db import get_db_connection
from utils import generate_client_code, generate_service_code from utils import generate_client_code, generate_service_code
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from io import BytesIO
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
app = Flask( app = Flask(
__name__, __name__,
template_folder="../templates", template_folder="../templates",
@ -26,6 +30,10 @@ APP_VERSION = load_version()
def inject_version(): def inject_version():
return {"app_version": APP_VERSION} return {"app_version": APP_VERSION}
@app.context_processor
def inject_app_settings():
return {"app_settings": get_app_settings()}
def fmt_local(dt_value): def fmt_local(dt_value):
if not dt_value: if not dt_value:
return "" return ""
@ -70,7 +78,7 @@ def recalc_invoice_totals(invoice_id):
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute(""" cursor.execute("""
SELECT id, total_amount, due_at SELECT id, total_amount, due_at, status
FROM invoices FROM invoices
WHERE id = %s WHERE id = %s
""", (invoice_id,)) """, (invoice_id,))
@ -91,6 +99,21 @@ def recalc_invoice_totals(invoice_id):
total_paid = to_decimal(row["total_paid"]) total_paid = to_decimal(row["total_paid"])
total_amount = to_decimal(invoice["total_amount"]) 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: if total_paid >= total_amount and total_amount > 0:
new_status = "paid" new_status = "paid"
paid_at_value = "UTC_TIMESTAMP()" paid_at_value = "UTC_TIMESTAMP()"
@ -132,6 +155,112 @@ def get_client_credit_balance(client_id):
conn.close() conn.close()
return to_decimal(row["balance"]) 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}"
APP_SETTINGS_DEFAULTS = {
"business_name": "OTB Billing",
"business_tagline": "By a contractor, for contractors",
"business_email": "",
"business_phone": "",
"business_address": "",
"business_website": "",
"tax_label": "HST",
"tax_rate": "13.00",
"tax_number": "",
"business_number": "",
"default_currency": "CAD",
"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",
}
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") @app.template_filter("localtime")
def localtime_filter(value): def localtime_filter(value):
return fmt_local(value) return fmt_local(value)
@ -140,6 +269,18 @@ def localtime_filter(value):
def money_filter(value, currency_code="CAD"): def money_filter(value, currency_code="CAD"):
return fmt_money(value, currency_code) return fmt_money(value, currency_code)
@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("/") @app.route("/")
def index(): def index():
refresh_overdue_invoices() refresh_overdue_invoices()
@ -663,10 +804,7 @@ def new_invoice():
form_data=form_data, form_data=form_data,
) )
cursor.execute("SELECT MAX(id) AS last_id FROM invoices") invoice_number = generate_invoice_number()
result = cursor.fetchone()
number = (result["last_id"] or 0) + 1
invoice_number = f"INV-{number:04d}"
insert_cursor = conn.cursor() insert_cursor = conn.cursor()
insert_cursor.execute(""" insert_cursor.execute("""
@ -716,6 +854,238 @@ def new_invoice():
form_data={}, 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']}")
pdf.setFont("Helvetica-Bold", 22)
pdf.drawString(left, 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):
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()
return render_template("invoices/view.html", invoice=invoice, settings=settings)
@app.route("/invoices/edit/<int:invoice_id>", methods=["GET", "POST"]) @app.route("/invoices/edit/<int:invoice_id>", methods=["GET", "POST"])
def edit_invoice(invoice_id): def edit_invoice(invoice_id):
conn = get_db_connection() conn = get_db_connection()
@ -740,22 +1110,14 @@ def edit_invoice(invoice_id):
notes = request.form.get("notes", "").strip() notes = request.form.get("notes", "").strip()
if locked: if locked:
status = request.form.get("status", "").strip()
if not status:
conn.close()
return render_template("invoices/edit.html", invoice=invoice, clients=[], services=[], errors=["Status is required."], locked=locked)
update_cursor = conn.cursor() update_cursor = conn.cursor()
update_cursor.execute(""" update_cursor.execute("""
UPDATE invoices UPDATE invoices
SET due_at = %s, SET due_at = %s,
status = %s,
notes = %s notes = %s
WHERE id = %s WHERE id = %s
""", ( """, (
due_at or None, due_at or None,
status,
notes or None, notes or None,
invoice_id invoice_id
)) ))
@ -784,6 +1146,10 @@ def edit_invoice(invoice_id):
if not status: if not status:
errors.append("Status is required.") 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: if not errors:
try: try:
amount_value = float(total_amount) amount_value = float(total_amount)
@ -858,6 +1224,10 @@ def payments():
SELECT SELECT
p.*, p.*,
i.invoice_number, 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.client_code,
c.company_name c.company_name
FROM payments p FROM payments p
@ -900,35 +1270,74 @@ def new_payment():
if not cad_value_at_payment: if not cad_value_at_payment:
errors.append("CAD value at payment is required.") errors.append("CAD value at payment is required.")
amount_value = None
if not errors: if not errors:
try: try:
amount_value = float(payment_amount) payment_amount_value = Decimal(str(payment_amount))
if amount_value <= 0: if payment_amount_value <= Decimal("0"):
errors.append("Payment amount must be greater than zero.") errors.append("Payment amount must be greater than zero.")
except ValueError: except Exception:
errors.append("Payment amount must be a valid number.") errors.append("Payment amount must be a valid number.")
if not errors:
try: try:
cad_value = float(cad_value_at_payment) cad_value_value = Decimal(str(cad_value_at_payment))
if cad_value < 0: if cad_value_value < Decimal("0"):
errors.append("CAD value at payment cannot be negative.") errors.append("CAD value at payment cannot be negative.")
except ValueError: except Exception:
errors.append("CAD value at payment must be a valid number.") errors.append("CAD value at payment must be a valid number.")
if errors: invoice_row = None
if not errors:
cursor.execute(""" cursor.execute("""
SELECT SELECT
i.id, i.id,
i.client_id,
i.invoice_number, i.invoice_number,
i.currency_code,
i.total_amount,
i.amount_paid,
i.status,
c.client_code, c.client_code,
c.company_name, 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.total_amount,
i.amount_paid, i.amount_paid,
i.currency_code i.status,
c.client_code,
c.company_name
FROM invoices i FROM invoices i
JOIN clients c ON i.client_id = c.id 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 ORDER BY i.id DESC
""") """)
invoices = cursor.fetchall() invoices = cursor.fetchall()
@ -954,14 +1363,7 @@ def new_payment():
form_data=form_data, form_data=form_data,
) )
cursor.execute("SELECT client_id FROM invoices WHERE id = %s", (invoice_id,)) client_id = invoice_row["client_id"]
invoice = cursor.fetchone()
if not invoice:
conn.close()
return "Invoice not found", 404
client_id = invoice["client_id"]
insert_cursor = conn.cursor() insert_cursor = conn.cursor()
insert_cursor.execute(""" insert_cursor.execute("""
@ -1007,13 +1409,16 @@ def new_payment():
SELECT SELECT
i.id, i.id,
i.invoice_number, i.invoice_number,
c.client_code, i.currency_code,
c.company_name,
i.total_amount, i.total_amount,
i.amount_paid, i.amount_paid,
i.currency_code i.status,
c.client_code,
c.company_name
FROM invoices i FROM invoices i
JOIN clients c ON i.client_id = c.id 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 ORDER BY i.id DESC
""") """)
invoices = cursor.fetchall() invoices = cursor.fetchall()
@ -1026,6 +1431,46 @@ def new_payment():
form_data={}, 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"]) @app.route("/payments/edit/<int:payment_id>", methods=["GET", "POST"])
def edit_payment(payment_id): def edit_payment(payment_id):
conn = get_db_connection() conn = get_db_connection()

5
requirements.txt

@ -0,0 +1,5 @@
Flask
mysql-connector-python
reportlab
python-dateutil
pytz

3
templates/dashboard.html

@ -5,12 +5,13 @@
</head> </head>
<body> <body>
<h1>OTB Billing Dashboard</h1> <h1>{{ app_settings.business_name or 'OTB Billing' }} Dashboard</h1>
<p><a href="/clients">Clients</a></p> <p><a href="/clients">Clients</a></p>
<p><a href="/services">Services</a></p> <p><a href="/services">Services</a></p>
<p><a href="/invoices">Invoices</a></p> <p><a href="/invoices">Invoices</a></p>
<p><a href="/payments">Payments</a></p> <p><a href="/payments">Payments</a></p>
<p><a href="/settings">Settings / Config</a></p>
<p><a href="/dbtest">DB Test</a></p> <p><a href="/dbtest">DB Test</a></p>
<table border="1" cellpadding="10"> <table border="1" cellpadding="10">

58
templates/invoices/edit.html

@ -2,6 +2,41 @@
<html> <html>
<head> <head>
<title>Edit Invoice</title> <title>Edit Invoice</title>
<style>
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-draft { background: #e5e7eb; color: #111827; }
.status-pending { background: #dbeafe; color: #1d4ed8; }
.status-partial { background: #fef3c7; color: #92400e; }
.status-paid { background: #dcfce7; color: #166534; }
.status-overdue { background: #fee2e2; color: #991b1b; }
.status-cancelled { background: #e5e7eb; color: #4b5563; }
.info-box {
border: 1px solid #2563eb;
background: #eff6ff;
padding: 10px;
margin-bottom: 15px;
}
.warn-box {
border: 1px solid #aa6600;
background: #fff4dd;
padding: 10px;
margin-bottom: 15px;
}
.error-box {
border: 1px solid red;
padding: 10px;
margin-bottom: 15px;
}
</style>
</head> </head>
<body> <body>
@ -12,7 +47,7 @@
<p><a href="/invoices">Back to Invoices</a></p> <p><a href="/invoices">Back to Invoices</a></p>
{% if errors %} {% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;"> <div class="error-box">
<strong>Please fix the following:</strong> <strong>Please fix the following:</strong>
<ul> <ul>
{% for error in errors %} {% for error in errors %}
@ -23,10 +58,15 @@
{% endif %} {% endif %}
{% if locked %} {% if locked %}
<div style="border:1px solid #aa6600; padding:10px; margin-bottom:15px; background:#fff4dd;"> <div class="warn-box">
<strong>This invoice is locked for core edits because payments exist.</strong><br> <strong>This invoice is locked for core edits because payments exist.</strong><br>
Core accounting fields cannot be changed after payment activity begins. Core accounting fields cannot be changed after payment activity begins.
</div> </div>
{% else %}
<div class="info-box">
<strong>Manual status choices are limited to:</strong> draft, pending, or cancelled.<br>
Partial, paid, and overdue are system-managed from payment activity and due dates.
</div>
{% endif %} {% endif %}
<form method="post"> <form method="post">
@ -74,8 +114,8 @@ Total Amount *<br>
<input type="number" step="0.00000001" min="0" name="total_amount" value="{{ invoice.total_amount }}" required> <input type="number" step="0.00000001" min="0" name="total_amount" value="{{ invoice.total_amount }}" required>
</p> </p>
{% else %} {% else %}
<p>Client<br><input value="{{ invoice.client_id }}" readonly></p> <p>Client ID<br><input value="{{ invoice.client_id }}" readonly></p>
<p>Service<br><input value="{{ invoice.service_id }}" readonly></p> <p>Service ID<br><input value="{{ invoice.service_id }}" readonly></p>
<p>Currency<br><input value="{{ invoice.currency_code }}" readonly></p> <p>Currency<br><input value="{{ invoice.currency_code }}" readonly></p>
<p>Total Amount<br><input value="{{ invoice.total_amount|money(invoice.currency_code) }}" readonly></p> <p>Total Amount<br><input value="{{ invoice.total_amount|money(invoice.currency_code) }}" readonly></p>
{% endif %} {% endif %}
@ -85,17 +125,21 @@ Due Date *<br>
<input type="date" name="due_at" value="{{ invoice.due_at.strftime('%Y-%m-%d') if invoice.due_at else '' }}" required> <input type="date" name="due_at" value="{{ invoice.due_at.strftime('%Y-%m-%d') if invoice.due_at else '' }}" required>
</p> </p>
{% if locked %}
<p>
System Status<br>
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span>
</p>
{% else %}
<p> <p>
Status *<br> Status *<br>
<select name="status" required> <select name="status" required>
<option value="draft" {% if invoice.status == 'draft' %}selected{% endif %}>draft</option> <option value="draft" {% if invoice.status == 'draft' %}selected{% endif %}>draft</option>
<option value="pending" {% if invoice.status == 'pending' %}selected{% endif %}>pending</option> <option value="pending" {% if invoice.status == 'pending' %}selected{% endif %}>pending</option>
<option value="partial" {% if invoice.status == 'partial' %}selected{% endif %}>partial</option>
<option value="paid" {% if invoice.status == 'paid' %}selected{% endif %}>paid</option>
<option value="overdue" {% if invoice.status == 'overdue' %}selected{% endif %}>overdue</option>
<option value="cancelled" {% if invoice.status == 'cancelled' %}selected{% endif %}>cancelled</option> <option value="cancelled" {% if invoice.status == 'cancelled' %}selected{% endif %}>cancelled</option>
</select> </select>
</p> </p>
{% endif %}
<p> <p>
Notes<br> Notes<br>

30
templates/invoices/list.html

@ -2,6 +2,28 @@
<html> <html>
<head> <head>
<title>Invoices</title> <title>Invoices</title>
<style>
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-draft { background: #e5e7eb; color: #111827; }
.status-pending { background: #dbeafe; color: #1d4ed8; }
.status-partial { background: #fef3c7; color: #92400e; }
.status-paid { background: #dcfce7; color: #166534; }
.status-overdue { background: #fee2e2; color: #991b1b; }
.status-cancelled { background: #e5e7eb; color: #4b5563; }
.locked-note {
color: #92400e;
font-weight: bold;
}
</style>
</head> </head>
<body> <body>
@ -35,13 +57,17 @@
<td>{{ i.total_amount|money(i.currency_code) }}</td> <td>{{ i.total_amount|money(i.currency_code) }}</td>
<td>{{ i.amount_paid|money(i.currency_code) }}</td> <td>{{ i.amount_paid|money(i.currency_code) }}</td>
<td>{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }}</td> <td>{{ (i.total_amount - i.amount_paid)|money(i.currency_code) }}</td>
<td>{{ i.status }}</td> <td>
<span class="status-badge status-{{ i.status }}">{{ i.status }}</span>
</td>
<td>{{ i.issued_at|localtime }}</td> <td>{{ i.issued_at|localtime }}</td>
<td>{{ i.due_at|localtime }}</td> <td>{{ i.due_at|localtime }}</td>
<td> <td>
<a href="/invoices/view/{{ i.id }}">View</a> |
<a href="/invoices/pdf/{{ i.id }}">PDF</a> |
<a href="/invoices/edit/{{ i.id }}">Edit</a> <a href="/invoices/edit/{{ i.id }}">Edit</a>
{% if i.payment_count > 0 %} {% if i.payment_count > 0 %}
(Locked) <span class="locked-note">(Locked)</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

6
templates/invoices/new.html

@ -7,6 +7,11 @@
<body> <body>
<h1>Create Invoice</h1> <h1>Create Invoice</h1>
<p>
<strong>Invoice Number</strong><br>
This invoice number will be generated automatically when the invoice is created.
</p>
{% if errors %} {% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;"> <div style="border:1px solid red; padding:10px; margin-bottom:15px;">
@ -78,3 +83,4 @@ Notes<br>
</body> </body>
</html> </html>
{% include "footer.html" %}

202
templates/invoices/view.html

@ -0,0 +1,202 @@
<!doctype html>
<html>
<head>
<title>Invoice {{ invoice.invoice_number }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 30px;
color: #111;
}
.top-links {
margin-bottom: 20px;
}
.top-links a {
margin-right: 15px;
}
.invoice-wrap {
max-width: 900px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 25px;
}
.title-box h1 {
margin: 0 0 8px 0;
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.status-draft { background: #e5e7eb; color: #111827; }
.status-pending { background: #dbeafe; color: #1d4ed8; }
.status-partial { background: #fef3c7; color: #92400e; }
.status-paid { background: #dcfce7; color: #166534; }
.status-overdue { background: #fee2e2; color: #991b1b; }
.status-cancelled { background: #e5e7eb; color: #4b5563; }
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 25px;
}
.info-card {
border: 1px solid #ccc;
padding: 15px;
}
.info-card h3 {
margin-top: 0;
}
.summary-table,
.total-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 25px;
}
.summary-table th,
.summary-table td,
.total-table th,
.total-table td {
border: 1px solid #ccc;
padding: 10px;
text-align: left;
}
.total-table {
max-width: 420px;
margin-left: auto;
}
.notes-box {
border: 1px solid #ccc;
padding: 15px;
white-space: pre-wrap;
margin-top: 20px;
}
.print-only {
display: none;
}
@media print {
.top-links,
.screen-only,
footer {
display: none !important;
}
.print-only {
display: block;
}
body {
margin: 0;
}
}
</style>
</head>
<body>
<div class="invoice-wrap">
<div class="top-links screen-only">
<a href="/">Home</a>
<a href="/invoices">Back to Invoices</a>
<a href="/invoices/edit/{{ invoice.id }}">Edit Invoice</a>
<a href="#" onclick="window.print(); return false;">Print</a>
<a href="/invoices/pdf/{{ invoice.id }}">PDF</a>
</div>
<div class="header-row">
<div class="title-box">
<h1>Invoice {{ invoice.invoice_number }}</h1>
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span>
</div>
<div style="text-align:right;">
<strong>{{ settings.business_name or 'OTB Billing' }}</strong><br>
{{ settings.business_tagline or '' }}<br>
{% if settings.business_address %}{{ settings.business_address }}<br>{% endif %}
{% if settings.business_email %}{{ settings.business_email }}<br>{% endif %}
{% if settings.business_phone %}{{ settings.business_phone }}<br>{% endif %}
{% if settings.business_website %}{{ settings.business_website }}{% endif %}
</div>
</div>
<div class="info-grid">
<div class="info-card">
<h3>Bill To</h3>
<strong>{{ invoice.company_name }}</strong><br>
{% if invoice.contact_name %}{{ invoice.contact_name }}<br>{% endif %}
{% if invoice.email %}{{ invoice.email }}<br>{% endif %}
{% if invoice.phone %}{{ invoice.phone }}<br>{% endif %}
Client Code: {{ invoice.client_code }}
</div>
<div class="info-card">
<h3>Invoice Details</h3>
Invoice #: {{ invoice.invoice_number }}<br>
Issued: {{ invoice.issued_at|localtime }}<br>
Due: {{ invoice.due_at|localtime }}<br>
{% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}<br>{% endif %}
Currency: {{ invoice.currency_code }}<br>
{% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}<br>{% endif %}
{% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %}
</div>
</div>
<table class="summary-table">
<tr>
<th>Service Code</th>
<th>Service</th>
<th>Description</th>
<th>Total</th>
</tr>
<tr>
<td>{{ invoice.service_code or '-' }}</td>
<td>{{ invoice.service_name or '-' }}</td>
<td>{{ invoice.notes or '-' }}</td>
<td>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
</table>
<table class="total-table">
<tr>
<th>Subtotal</th>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
<tr>
<th>{{ settings.tax_label or 'Tax' }}</th>
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
<tr>
<th>Total</th>
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td>
</tr>
<tr>
<th>Paid</th>
<td>{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
<tr>
<th>Remaining</th>
<td>{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
</table>
{% if settings.payment_terms %}
<div class="notes-box">
<strong>Payment Terms</strong><br><br>
{{ settings.payment_terms }}
</div>
{% endif %}
{% if settings.invoice_footer %}
<div class="notes-box">
<strong>Footer</strong><br><br>
{{ settings.invoice_footer }}
</div>
{% endif %}
</div>
{% include "footer.html" %}
</body>
</html>

34
templates/payments/edit.html

@ -2,6 +2,25 @@
<html> <html>
<head> <head>
<title>Edit Payment</title> <title>Edit Payment</title>
<style>
.info-box {
border: 1px solid #2563eb;
background: #eff6ff;
padding: 10px;
margin-bottom: 15px;
}
.warn-box {
border: 1px solid #aa6600;
background: #fff4dd;
padding: 10px;
margin-bottom: 15px;
}
.error-box {
border: 1px solid red;
padding: 10px;
margin-bottom: 15px;
}
</style>
</head> </head>
<body> <body>
@ -10,8 +29,19 @@
<p><a href="/">Home</a></p> <p><a href="/">Home</a></p>
<p><a href="/payments">Back to Payments</a></p> <p><a href="/payments">Back to Payments</a></p>
<div class="info-box">
Payment edits are validated against the invoice balance.
</div>
<div class="warn-box">
<strong>Important payment policy:</strong><br>
Do not increase a payment beyond the invoice balance.<br>
If a client wants money carried forward to the next bill, that must be added separately as client credit.<br>
Overpayment is not used to create account credit.
</div>
{% if errors %} {% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;"> <div class="error-box">
<strong>Please fix the following:</strong> <strong>Please fix the following:</strong>
<ul> <ul>
{% for error in errors %} {% for error in errors %}
@ -93,7 +123,7 @@ Wallet Address<br>
<p> <p>
Notes<br> Notes<br>
<textarea name="notes" rows="5" cols="60">{{ payment.notes or '' }}</textarea> <textarea name="notes">{{ payment.notes or '' }}</textarea>
</p> </p>
<p> <p>

62
templates/payments/list.html

@ -2,6 +2,50 @@
<html> <html>
<head> <head>
<title>Payments</title> <title>Payments</title>
<style>
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-confirmed { background: #dcfce7; color: #166534; }
.status-reversed { background: #fee2e2; color: #991b1b; }
.invoice-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.invoice-draft { background: #e5e7eb; color: #111827; }
.invoice-pending { background: #dbeafe; color: #1d4ed8; }
.invoice-partial { background: #fef3c7; color: #92400e; }
.invoice-paid { background: #dcfce7; color: #166534; }
.invoice-overdue { background: #fee2e2; color: #991b1b; }
.invoice-cancelled { background: #e5e7eb; color: #4b5563; }
.inline-form {
display: inline;
margin: 0;
}
.void-btn {
background: #991b1b;
color: white;
border: 0;
padding: 4px 8px;
cursor: pointer;
}
.void-btn:hover {
opacity: 0.9;
}
</style>
</head> </head>
<body> <body>
@ -19,7 +63,9 @@
<th>Currency</th> <th>Currency</th>
<th>Amount</th> <th>Amount</th>
<th>CAD Value</th> <th>CAD Value</th>
<th>Reference</th> <th>Payment Status</th>
<th>Invoice Status</th>
<th>Remaining</th>
<th>Received</th> <th>Received</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@ -33,9 +79,19 @@
<td>{{ p.payment_currency }}</td> <td>{{ p.payment_currency }}</td>
<td>{{ p.payment_amount|money(p.payment_currency) }}</td> <td>{{ p.payment_amount|money(p.payment_currency) }}</td>
<td>{{ p.cad_value_at_payment|money('CAD') }}</td> <td>{{ p.cad_value_at_payment|money('CAD') }}</td>
<td>{{ p.reference }}</td> <td><span class="status-badge status-{{ p.payment_status }}">{{ p.payment_status }}</span></td>
<td><span class="invoice-badge invoice-{{ p.invoice_status }}">{{ p.invoice_status }}</span></td>
<td>{{ (p.total_amount - p.amount_paid)|money(p.invoice_currency_code) }}</td>
<td>{{ p.received_at|localtime }}</td> <td>{{ p.received_at|localtime }}</td>
<td><a href="/payments/edit/{{ p.id }}">Edit</a></td> <td>
<a href="/payments/edit/{{ p.id }}">Edit</a>
{% if p.payment_status == 'confirmed' %}
|
<form method="post" action="/payments/void/{{ p.id }}" class="inline-form" onsubmit="return confirm('Void this payment? This will reverse it from invoice totals but keep the record for history.');">
<button type="submit" class="void-btn">Void</button>
</form>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}

41
templates/payments/new.html

@ -2,13 +2,47 @@
<html> <html>
<head> <head>
<title>New Payment</title> <title>New Payment</title>
<style>
.info-box {
border: 1px solid #2563eb;
background: #eff6ff;
padding: 10px;
margin-bottom: 15px;
}
.warn-box {
border: 1px solid #aa6600;
background: #fff4dd;
padding: 10px;
margin-bottom: 15px;
}
.error-box {
border: 1px solid red;
padding: 10px;
margin-bottom: 15px;
}
</style>
</head> </head>
<body> <body>
<h1>Record Payment</h1> <h1>Record Payment</h1>
<p><a href="/">Home</a></p>
<p><a href="/payments">Back to Payments</a></p>
<div class="info-box">
Only invoices with an outstanding balance are shown here.<br>
Paid and cancelled invoices are excluded from payment entry.
</div>
<div class="warn-box">
<strong>Important payment policy:</strong><br>
Do not pay more than 100% of an invoice through this page.<br>
If a client wants money carried forward to the next bill, that must be added separately as client credit.<br>
Overpayment is not used to create account credit.
</div>
{% if errors %} {% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;"> <div class="error-box">
<strong>Please fix the following:</strong> <strong>Please fix the following:</strong>
<ul> <ul>
{% for error in errors %} {% for error in errors %}
@ -26,7 +60,9 @@ Invoice *<br>
<option value="">Select invoice</option> <option value="">Select invoice</option>
{% for i in invoices %} {% for i in invoices %}
<option value="{{ i.id }}" {% if form_data.get('invoice_id') == (i.id|string) %}selected{% endif %}> <option value="{{ i.id }}" {% if form_data.get('invoice_id') == (i.id|string) %}selected{% endif %}>
{{ i.invoice_number }} - {{ i.client_code }} - {{ i.company_name }} - Due {{ i.total_amount - i.amount_paid }} {{ i.currency_code }} {{ i.invoice_number }} - {{ i.client_code }} - {{ i.company_name }} -
Remaining {{ (i.total_amount - i.amount_paid)|money(i.currency_code) }} {{ i.currency_code }} -
{{ i.status }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@ -98,5 +134,6 @@ Notes<br>
</form> </form>
{% include "footer.html" %}
</body> </body>
</html> </html>

169
templates/settings.html

@ -0,0 +1,169 @@
<!doctype html>
<html>
<head>
<title>Settings</title>
<style>
body { font-family: Arial, sans-serif; }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 1100px;
}
.card {
border: 1px solid #ccc;
padding: 16px;
}
.card h2 {
margin-top: 0;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
textarea,
select {
width: 100%;
box-sizing: border-box;
margin-top: 4px;
margin-bottom: 12px;
padding: 8px;
}
textarea { min-height: 90px; }
.checkbox-row {
margin: 8px 0 14px 0;
}
.save-row {
margin-top: 18px;
}
</style>
</head>
<body>
<h1>Settings / Config</h1>
<p><a href="/">Home</a></p>
<form method="post">
<div class="form-grid">
<div class="card">
<h2>Business Identity</h2>
Business Name<br>
<input type="text" name="business_name" value="{{ settings.business_name }}"><br>
Slogan / Tagline<br>
<input type="text" name="business_tagline" value="{{ settings.business_tagline }}"><br>
Business Email<br>
<input type="email" name="business_email" value="{{ settings.business_email }}"><br>
Business Phone<br>
<input type="text" name="business_phone" value="{{ settings.business_phone }}"><br>
Business Address<br>
<textarea name="business_address">{{ settings.business_address }}</textarea><br>
Website<br>
<input type="text" name="business_website" value="{{ settings.business_website }}"><br>
Business Number / Registration Number<br>
<input type="text" name="business_number" value="{{ settings.business_number }}"><br>
Default Currency<br>
<select name="default_currency">
<option value="CAD" {% if settings.default_currency == 'CAD' %}selected{% endif %}>CAD</option>
<option value="USD" {% if settings.default_currency == 'USD' %}selected{% endif %}>USD</option>
<option value="ETHO" {% if settings.default_currency == 'ETHO' %}selected{% endif %}>ETHO</option>
<option value="EGAZ" {% if settings.default_currency == 'EGAZ' %}selected{% endif %}>EGAZ</option>
<option value="ALT" {% if settings.default_currency == 'ALT' %}selected{% endif %}>ALT</option>
</select>
</div>
<div class="card">
<h2>Tax Settings</h2>
Local Country<br>
<input type="text" name="local_country" value="{{ settings.local_country }}"><br>
Tax Label<br>
<input type="text" name="tax_label" value="{{ settings.tax_label }}"><br>
Tax Rate (%)<br>
<input type="number" step="0.01" name="tax_rate" value="{{ settings.tax_rate }}"><br>
Tax Number<br>
<input type="text" name="tax_number" value="{{ settings.tax_number }}"><br>
<div class="checkbox-row">
<label>
<input type="checkbox" name="apply_local_tax_only" value="1" {% if settings.apply_local_tax_only == '1' %}checked{% endif %}>
Apply tax only to local clients
</label>
</div>
Payment Terms<br>
<textarea name="payment_terms">{{ settings.payment_terms }}</textarea><br>
Invoice Footer<br>
<textarea name="invoice_footer">{{ settings.invoice_footer }}</textarea><br>
</div>
<div class="card">
<h2>Email / SMTP</h2>
SMTP Host<br>
<input type="text" name="smtp_host" value="{{ settings.smtp_host }}"><br>
SMTP Port<br>
<input type="number" name="smtp_port" value="{{ settings.smtp_port }}"><br>
SMTP Username<br>
<input type="text" name="smtp_user" value="{{ settings.smtp_user }}"><br>
SMTP Password<br>
<input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}"><br>
From Email<br>
<input type="email" name="smtp_from_email" value="{{ settings.smtp_from_email }}"><br>
From Name<br>
<input type="text" name="smtp_from_name" value="{{ settings.smtp_from_name }}"><br>
<div class="checkbox-row">
<label>
<input type="checkbox" name="smtp_use_tls" value="1" {% if settings.smtp_use_tls == '1' %}checked{% endif %}>
Use TLS
</label>
</div>
<div class="checkbox-row">
<label>
<input type="checkbox" name="smtp_use_ssl" value="1" {% if settings.smtp_use_ssl == '1' %}checked{% endif %}>
Use SSL
</label>
</div>
</div>
<div class="card">
<h2>Notes</h2>
<p>
These settings become the identity and delivery configuration for this installation.
</p>
<p>
Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them.
</p>
<p>
Tax settings are also stored now so invoice and automation logic can use them later.
</p>
</div>
</div>
<div class="save-row">
<button type="submit">Save Settings</button>
</div>
</form>
{% include "footer.html" %}
</body>
</html>
Loading…
Cancel
Save