diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 569b0a8..b48e163 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,53 +1,96 @@ -# OTB Billing – Project State +# OTB Billing — Project State -Version: v0.3-dev -Status: Active Development -Backend: Flask (Python) -Database: MariaDB -Default Port: 5050 -Install Location: ~/otb_billing +Last Updated: 2026-03-09 +Version: v0.3-dev +Project Path: ~/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 -- deployable via installer -- database-driven -- portable across Linux servers -- simple HTML frontend with strong backend logic +- self-hosted +- portable +- database-backed +- deployable on fresh Linux systems +- 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 -2) managed hosted deployments under outsidethebox.top +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 -## Client Management -- create/edit clients -- client code system -- client contact details -- client ledger link +## Clients + +- create client +- edit client +- list clients +- status field +- client code support ## Services -- service code system -- service descriptions -- reusable services for invoices + +- create service +- edit service +- list services +- service code support +- service status support ## 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 - pending - partial @@ -56,186 +99,202 @@ Statuses: - cancelled ## 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 -- positive / zero / negative balance color indicators - manual credit entries +- client balance color coding +- ledger link visible from client list/edit pages ## Invoice Rendering + - HTML invoice view -- printer friendly layout -- invoice totals / remaining balance display -- payment status badges +- print-friendly layout +- PDF invoice generation +- 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 -Database-stored settings accessible at: +# Current Settings / Config System + +Accessible from: /settings -Configurable fields: +Stored in database table: + +app_settings + +## Business Identity Settings -Business Identity - business name -- slogan / tagline +- business tagline - business email - business phone - business address -- website +- business website - business registration number -Tax Settings +## Tax Settings + - tax label - tax rate - tax number - local country -- apply tax only to local clients +- apply local tax only flag + +## Invoice Behavior Settings -Invoice Behavior +- default currency - invoice footer - payment terms -- default currency -SMTP / Email (prepared for future email invoices) +## SMTP / Email Settings + - SMTP host - SMTP port - SMTP username - SMTP password -- from email -- from name -- TLS / SSL flags +- SMTP from email +- SMTP from name +- TLS flag +- SSL flag + +Email sending is not yet wired, but config storage is now in place. --- -# Architecture +# Current Known Good State -Backend: -Flask +Confirmed working: -Database: -MariaDB using mysql-connector-python +- dashboard +- 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 ---- - -# Installer Philosophy +This file must remain complete so installer-driven deployment works in one shot. -OTB Billing is designed to support automated installs. +--- -Target workflow: +# Business / Product Direction -fresh server -→ run installer -→ install python dependencies -→ install MariaDB -→ create schema -→ launch application +This system is intended to grow into a deployable billing product for small contractors and related service businesses. -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 -- installer instructions -- deployment examples +Long-term success goal: +build something users are happy to use and proud to own. --- -# 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 ---- - -# Planned Features +## Medium-Term -## Near Term +- quote / estimate system +- recurring invoices +- reminder workflows +- improved branding/theme polish +- better installer/update flow -Email invoices -- attach generated PDF -- send via configured SMTP - -Invoice defaults -- default currency -- default tax rules +## Long-Term -Business branding -- configurable business logo -- invoice header logo +- client portal +- role-based access +- 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 -Recurring invoices -Invoice reminders -Client portal +Business identity and SMTP belong in settings UI. -## Long Term +Database credentials should remain installer/config-file driven, not casually editable in standard UI. -Multi-currency handling -Exchange rate support -Accounting export (CSV / QuickBooks style) -API access -User authentication system -Role permissions +If advanced connection settings are ever exposed in UI, they must be clearly marked as dangerous / advanced and should avoid redisplaying stored passwords. --- -# Deployment Goals +# Repository Discipline -OTB Billing should support: +For this project going forward: -Self-host installs -Managed hosting deployments -Multi-client environments +- 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 -Target integration with: +Example future archive naming: -outsidethebox.top hosting services. +otb_billing-v0.3.0.zip --- -# Repository Discipline - -Every version bump must: - -- update PROJECT_STATE.md -- update README.md changelog -- produce full version snapshot ZIP backup +# Restart / Run Notes -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. diff --git a/backend/app.py b/backend/app.py index fb4d762..6e526ce 100644 --- a/backend/app.py +++ b/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 utils import generate_client_code, generate_service_code from datetime import datetime, timezone from zoneinfo import ZoneInfo from decimal import Decimal, InvalidOperation +from io import BytesIO +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas + app = Flask( __name__, template_folder="../templates", @@ -26,6 +30,10 @@ APP_VERSION = load_version() def inject_version(): return {"app_version": APP_VERSION} +@app.context_processor +def inject_app_settings(): + return {"app_settings": get_app_settings()} + def fmt_local(dt_value): if not dt_value: return "" @@ -70,7 +78,7 @@ def recalc_invoice_totals(invoice_id): cursor = conn.cursor(dictionary=True) cursor.execute(""" - SELECT id, total_amount, due_at + SELECT id, total_amount, due_at, status FROM invoices WHERE id = %s """, (invoice_id,)) @@ -91,6 +99,21 @@ def recalc_invoice_totals(invoice_id): total_paid = to_decimal(row["total_paid"]) 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: new_status = "paid" paid_at_value = "UTC_TIMESTAMP()" @@ -132,6 +155,112 @@ def get_client_credit_balance(client_id): conn.close() 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") def localtime_filter(value): return fmt_local(value) @@ -140,6 +269,18 @@ def localtime_filter(value): def money_filter(value, currency_code="CAD"): 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("/") def index(): refresh_overdue_invoices() @@ -663,10 +804,7 @@ def new_invoice(): form_data=form_data, ) - cursor.execute("SELECT MAX(id) AS last_id FROM invoices") - result = cursor.fetchone() - number = (result["last_id"] or 0) + 1 - invoice_number = f"INV-{number:04d}" + invoice_number = generate_invoice_number() insert_cursor = conn.cursor() insert_cursor.execute(""" @@ -716,6 +854,238 @@ def new_invoice(): form_data={}, ) + + + + +@app.route("/invoices/pdf/") +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/") +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/", methods=["GET", "POST"]) def edit_invoice(invoice_id): conn = get_db_connection() @@ -740,22 +1110,14 @@ def edit_invoice(invoice_id): notes = request.form.get("notes", "").strip() 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.execute(""" UPDATE invoices SET due_at = %s, - status = %s, notes = %s WHERE id = %s """, ( due_at or None, - status, notes or None, invoice_id )) @@ -784,6 +1146,10 @@ def edit_invoice(invoice_id): if not status: 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: try: amount_value = float(total_amount) @@ -858,6 +1224,10 @@ def payments(): SELECT p.*, 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.company_name FROM payments p @@ -900,35 +1270,74 @@ def new_payment(): if not cad_value_at_payment: errors.append("CAD value at payment is required.") - amount_value = None - if not errors: try: - amount_value = float(payment_amount) - if amount_value <= 0: + payment_amount_value = Decimal(str(payment_amount)) + if payment_amount_value <= Decimal("0"): errors.append("Payment amount must be greater than zero.") - except ValueError: + except Exception: errors.append("Payment amount must be a valid number.") + if not errors: try: - cad_value = float(cad_value_at_payment) - if cad_value < 0: + cad_value_value = Decimal(str(cad_value_at_payment)) + if cad_value_value < Decimal("0"): errors.append("CAD value at payment cannot be negative.") - except ValueError: + except Exception: errors.append("CAD value at payment must be a valid number.") - if errors: + invoice_row = None + + if not errors: cursor.execute(""" SELECT i.id, + i.client_id, i.invoice_number, + i.currency_code, + i.total_amount, + i.amount_paid, + i.status, 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.amount_paid, - i.currency_code + i.status, + c.client_code, + c.company_name FROM invoices i 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 """) invoices = cursor.fetchall() @@ -954,14 +1363,7 @@ def new_payment(): form_data=form_data, ) - cursor.execute("SELECT client_id FROM invoices WHERE id = %s", (invoice_id,)) - invoice = cursor.fetchone() - - if not invoice: - conn.close() - return "Invoice not found", 404 - - client_id = invoice["client_id"] + client_id = invoice_row["client_id"] insert_cursor = conn.cursor() insert_cursor.execute(""" @@ -1007,13 +1409,16 @@ def new_payment(): SELECT i.id, i.invoice_number, - c.client_code, - c.company_name, + i.currency_code, i.total_amount, i.amount_paid, - i.currency_code + i.status, + c.client_code, + c.company_name FROM invoices i 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 """) invoices = cursor.fetchall() @@ -1026,6 +1431,46 @@ def new_payment(): form_data={}, ) + + +@app.route("/payments/void/", 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/", methods=["GET", "POST"]) def edit_payment(payment_id): conn = get_db_connection() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0e458d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask +mysql-connector-python +reportlab +python-dateutil +pytz diff --git a/templates/dashboard.html b/templates/dashboard.html index 87908a5..9b98c6c 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -5,12 +5,13 @@ -

OTB Billing Dashboard

+

{{ app_settings.business_name or 'OTB Billing' }} Dashboard

Clients

Services

Invoices

Payments

+

Settings / Config

DB Test

diff --git a/templates/invoices/edit.html b/templates/invoices/edit.html index 5d2ab00..3ced0f8 100644 --- a/templates/invoices/edit.html +++ b/templates/invoices/edit.html @@ -2,6 +2,41 @@ Edit Invoice + @@ -12,7 +47,7 @@

Back to Invoices

{% if errors %} -
+
Please fix the following:
    {% for error in errors %} @@ -23,10 +58,15 @@ {% endif %} {% if locked %} -
    +
    This invoice is locked for core edits because payments exist.
    Core accounting fields cannot be changed after payment activity begins.
    +{% else %} +
    + Manual status choices are limited to: draft, pending, or cancelled.
    + Partial, paid, and overdue are system-managed from payment activity and due dates. +
    {% endif %}
    @@ -74,8 +114,8 @@ Total Amount *

    {% else %} -

    Client

    -

    Service

    +

    Client ID

    +

    Service ID

    Currency

    Total Amount

    {% endif %} @@ -85,17 +125,21 @@ Due Date *

    +{% if locked %} +

    +System Status
    +{{ invoice.status }} +

    +{% else %}

    Status *

    +{% endif %}

    Notes
    diff --git a/templates/invoices/list.html b/templates/invoices/list.html index 411a97c..6bbdddb 100644 --- a/templates/invoices/list.html +++ b/templates/invoices/list.html @@ -2,6 +2,28 @@ Invoices + @@ -35,13 +57,17 @@

- + diff --git a/templates/invoices/new.html b/templates/invoices/new.html index 14c3002..92523a6 100644 --- a/templates/invoices/new.html +++ b/templates/invoices/new.html @@ -7,6 +7,11 @@

Create Invoice

+

+Invoice Number
+This invoice number will be generated automatically when the invoice is created. +

+ {% if errors %}
@@ -78,3 +83,4 @@ Notes
+{% include "footer.html" %} diff --git a/templates/invoices/view.html b/templates/invoices/view.html new file mode 100644 index 0000000..4b2d16f --- /dev/null +++ b/templates/invoices/view.html @@ -0,0 +1,202 @@ + + + +Invoice {{ invoice.invoice_number }} + + + + +
+ + +
+
+

Invoice {{ invoice.invoice_number }}

+ {{ invoice.status }} +
+
+ {{ settings.business_name or 'OTB Billing' }}
+ {{ settings.business_tagline or '' }}
+ {% if settings.business_address %}{{ settings.business_address }}
{% endif %} + {% if settings.business_email %}{{ settings.business_email }}
{% endif %} + {% if settings.business_phone %}{{ settings.business_phone }}
{% endif %} + {% if settings.business_website %}{{ settings.business_website }}{% endif %} +
+
+ +
+
+

Bill To

+ {{ invoice.company_name }}
+ {% if invoice.contact_name %}{{ invoice.contact_name }}
{% endif %} + {% if invoice.email %}{{ invoice.email }}
{% endif %} + {% if invoice.phone %}{{ invoice.phone }}
{% endif %} + Client Code: {{ invoice.client_code }} +
+ +
+

Invoice Details

+ Invoice #: {{ invoice.invoice_number }}
+ Issued: {{ invoice.issued_at|localtime }}
+ Due: {{ invoice.due_at|localtime }}
+ {% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}
{% endif %} + Currency: {{ invoice.currency_code }}
+ {% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}
{% endif %} + {% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %} +
+
+ +
{{ i.total_amount|money(i.currency_code) }} {{ i.amount_paid|money(i.currency_code) }} {{ (i.total_amount - i.amount_paid)|money(i.currency_code) }}{{ i.status }} + {{ i.status }} + {{ i.issued_at|localtime }} {{ i.due_at|localtime }} + View | + PDF | Edit {% if i.payment_count > 0 %} - (Locked) + (Locked) {% endif %}
+ + + + + + + + + + + + +
Service CodeServiceDescriptionTotal
{{ invoice.service_code or '-' }}{{ invoice.service_name or '-' }}{{ invoice.notes or '-' }}{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
+ + + + + + + + + + + + + + + + + + + + + + +
Subtotal{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
{{ settings.tax_label or 'Tax' }}{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
Total{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}
Paid{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}
Remaining{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}
+ + {% if settings.payment_terms %} +
+ Payment Terms

+ {{ settings.payment_terms }} +
+ {% endif %} + + {% if settings.invoice_footer %} +
+ Footer

+ {{ settings.invoice_footer }} +
+ {% endif %} + + +{% include "footer.html" %} + + diff --git a/templates/payments/edit.html b/templates/payments/edit.html index 0c80a68..507b7fc 100644 --- a/templates/payments/edit.html +++ b/templates/payments/edit.html @@ -2,6 +2,25 @@ Edit Payment + @@ -10,8 +29,19 @@

Home

Back to Payments

+
+ Payment edits are validated against the invoice balance. +
+ +
+ Important payment policy:
+ Do not increase a payment beyond the invoice balance.
+ If a client wants money carried forward to the next bill, that must be added separately as client credit.
+ Overpayment is not used to create account credit. +
+ {% if errors %} -
+
Please fix the following:
    {% for error in errors %} @@ -93,7 +123,7 @@ Wallet Address

    Notes
    - +

    diff --git a/templates/payments/list.html b/templates/payments/list.html index 21298fa..6917328 100644 --- a/templates/payments/list.html +++ b/templates/payments/list.html @@ -2,6 +2,50 @@ Payments + @@ -19,7 +63,9 @@ Currency Amount CAD Value - Reference + Payment Status + Invoice Status + Remaining Received Actions @@ -33,9 +79,19 @@ {{ p.payment_currency }} {{ p.payment_amount|money(p.payment_currency) }} {{ p.cad_value_at_payment|money('CAD') }} - {{ p.reference }} + {{ p.payment_status }} + {{ p.invoice_status }} + {{ (p.total_amount - p.amount_paid)|money(p.invoice_currency_code) }} {{ p.received_at|localtime }} - Edit + + Edit + {% if p.payment_status == 'confirmed' %} + | + + + + {% endif %} + {% endfor %} diff --git a/templates/payments/new.html b/templates/payments/new.html index bff60a5..b64c957 100644 --- a/templates/payments/new.html +++ b/templates/payments/new.html @@ -2,13 +2,47 @@ New Payment +

    Record Payment

    +

    Home

    +

    Back to Payments

    + +
    + Only invoices with an outstanding balance are shown here.
    + Paid and cancelled invoices are excluded from payment entry. +
    + +
    + Important payment policy:
    + Do not pay more than 100% of an invoice through this page.
    + If a client wants money carried forward to the next bill, that must be added separately as client credit.
    + Overpayment is not used to create account credit. +
    + {% if errors %} -
    +
    Please fix the following:
      {% for error in errors %} @@ -26,7 +60,9 @@ Invoice *
      {% for i in invoices %} {% endfor %} @@ -98,5 +134,6 @@ Notes
      +{% include "footer.html" %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..cd47e91 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,169 @@ + + + +Settings + + + + +

      Settings / Config

      + +

      Home

      + +
      +
      +
      +

      Business Identity

      + + Business Name
      +
      + + Slogan / Tagline
      +
      + + Business Email
      +
      + + Business Phone
      +
      + + Business Address
      +
      + + Website
      +
      + + Business Number / Registration Number
      +
      + + Default Currency
      + +
      + +
      +

      Tax Settings

      + + Local Country
      +
      + + Tax Label
      +
      + + Tax Rate (%)
      +
      + + Tax Number
      +
      + +
      + +
      + + Payment Terms
      +
      + + Invoice Footer
      +
      +
      + +
      +

      Email / SMTP

      + + SMTP Host
      +
      + + SMTP Port
      +
      + + SMTP Username
      +
      + + SMTP Password
      +
      + + From Email
      +
      + + From Name
      +
      + +
      + +
      + +
      + +
      +
      + +
      +

      Notes

      +

      + These settings become the identity and delivery configuration for this installation. +

      +

      + Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them. +

      +

      + Tax settings are also stored now so invoice and automation logic can use them later. +

      +
      +
      + +
      + +
      +
      + +{% include "footer.html" %} + +