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

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 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/<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"])
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/<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"])
def edit_payment(payment_id):
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>
<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="/services">Services</a></p>
<p><a href="/invoices">Invoices</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>
<table border="1" cellpadding="10">

58
templates/invoices/edit.html

@ -2,6 +2,41 @@
<html>
<head>
<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>
<body>
@ -12,7 +47,7 @@
<p><a href="/invoices">Back to Invoices</a></p>
{% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;">
<div class="error-box">
<strong>Please fix the following:</strong>
<ul>
{% for error in errors %}
@ -23,10 +58,15 @@
{% endif %}
{% 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>
Core accounting fields cannot be changed after payment activity begins.
</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 %}
<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>
</p>
{% else %}
<p>Client<br><input value="{{ invoice.client_id }}" readonly></p>
<p>Service<br><input value="{{ invoice.service_id }}" readonly></p>
<p>Client ID<br><input value="{{ invoice.client_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>Total Amount<br><input value="{{ invoice.total_amount|money(invoice.currency_code) }}" readonly></p>
{% 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>
</p>
{% if locked %}
<p>
System Status<br>
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span>
</p>
{% else %}
<p>
Status *<br>
<select name="status" required>
<option value="draft" {% if invoice.status == 'draft' %}selected{% endif %}>draft</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>
</select>
</p>
{% endif %}
<p>
Notes<br>

30
templates/invoices/list.html

@ -2,6 +2,28 @@
<html>
<head>
<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>
<body>
@ -35,13 +57,17 @@
<td>{{ i.total_amount|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.status }}</td>
<td>
<span class="status-badge status-{{ i.status }}">{{ i.status }}</span>
</td>
<td>{{ i.issued_at|localtime }}</td>
<td>{{ i.due_at|localtime }}</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>
{% if i.payment_count > 0 %}
(Locked)
<span class="locked-note">(Locked)</span>
{% endif %}
</td>
</tr>

6
templates/invoices/new.html

@ -7,6 +7,11 @@
<body>
<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 %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;">
@ -78,3 +83,4 @@ Notes<br>
</body>
</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>
<head>
<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>
<body>
@ -10,8 +29,19 @@
<p><a href="/">Home</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 %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;">
<div class="error-box">
<strong>Please fix the following:</strong>
<ul>
{% for error in errors %}
@ -93,7 +123,7 @@ Wallet Address<br>
<p>
Notes<br>
<textarea name="notes" rows="5" cols="60">{{ payment.notes or '' }}</textarea>
<textarea name="notes">{{ payment.notes or '' }}</textarea>
</p>
<p>

62
templates/payments/list.html

@ -2,6 +2,50 @@
<html>
<head>
<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>
<body>
@ -19,7 +63,9 @@
<th>Currency</th>
<th>Amount</th>
<th>CAD Value</th>
<th>Reference</th>
<th>Payment Status</th>
<th>Invoice Status</th>
<th>Remaining</th>
<th>Received</th>
<th>Actions</th>
</tr>
@ -33,9 +79,19 @@
<td>{{ p.payment_currency }}</td>
<td>{{ p.payment_amount|money(p.payment_currency) }}</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><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>
{% endfor %}

41
templates/payments/new.html

@ -2,13 +2,47 @@
<html>
<head>
<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>
<body>
<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 %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;">
<div class="error-box">
<strong>Please fix the following:</strong>
<ul>
{% for error in errors %}
@ -26,7 +60,9 @@ Invoice *<br>
<option value="">Select invoice</option>
{% for i in invoices %}
<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>
{% endfor %}
</select>
@ -98,5 +134,6 @@ Notes<br>
</form>
{% include "footer.html" %}
</body>
</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