Browse Source

Bump to v3.0.1 multi-line invoice edit safety

main
def 3 weeks ago
parent
commit
d023c04df2
  1. 11
      PROJECT_STATE.md
  2. 11
      README.md
  3. 2
      VERSION
  4. 240
      backend/app.py
  5. 172
      templates/invoices/edit.html

11
PROJECT_STATE.md

@ -1,3 +1,14 @@
## v3.0.1 multi-line invoice edit safety - 2026-06-01 UTC
- Updated admin invoice edit workflow to preserve multi-line invoice items.
- Edit Invoice now loads existing `invoice_items` rows instead of treating the invoice as one total/notes row.
- Save Invoice now recalculates subtotal from edited line items.
- Save Invoice now preserves separate `subtotal_amount`, `tax_amount`, and `total_amount` fields.
- Save Invoice now recreates `invoice_items` from submitted line rows without flattening the invoice into one line.
- Added editable Tax / HST field plus a “Set 13% HST” helper button.
- Locked invoices with payment activity still protect core accounting fields and line items.
- This prevents converted invoices such as INV-0041 from being accidentally collapsed if opened and saved.
## v3.0.0 selected portal invoice downloads - 2026-05-29 UTC
- Added invoice selection checkboxes to the client portal dashboard.

11
README.md

@ -1,3 +1,14 @@
## v3.0.1 multi-line invoice edit safety - 2026-06-01 UTC
- Updated admin invoice edit workflow to preserve multi-line invoice items.
- Edit Invoice now loads existing `invoice_items` rows instead of treating the invoice as one total/notes row.
- Save Invoice now recalculates subtotal from edited line items.
- Save Invoice now preserves separate `subtotal_amount`, `tax_amount`, and `total_amount` fields.
- Save Invoice now recreates `invoice_items` from submitted line rows without flattening the invoice into one line.
- Added editable Tax / HST field plus a “Set 13% HST” helper button.
- Locked invoices with payment activity still protect core accounting fields and line items.
- This prevents converted invoices such as INV-0041 from being accidentally collapsed if opened and saved.
## v3.0.0 selected portal invoice downloads - 2026-05-29 UTC
- Added invoice selection checkboxes to the client portal dashboard.

2
VERSION

@ -1 +1 @@
v3.0.0
v3.0.1

240
backend/app.py

@ -4487,6 +4487,9 @@ def edit_invoice(invoice_id):
gate = admin_required()
if gate:
return gate
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
@ -4504,6 +4507,51 @@ def edit_invoice(invoice_id):
locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0
def _load_clients_services():
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
return clients, services
def _load_invoice_items():
cursor.execute("""
SELECT line_number, description, quantity, unit_amount, line_total, currency_code, service_id
FROM invoice_items
WHERE invoice_id = %s
ORDER BY line_number ASC, id ASC
""", (invoice_id,))
rows = cursor.fetchall()
if not rows:
rows = [{
"line_number": 1,
"description": invoice.get("notes") or "",
"quantity": Decimal("1.0000"),
"unit_amount": invoice.get("subtotal_amount") or invoice.get("total_amount") or Decimal("0"),
"line_total": invoice.get("subtotal_amount") or invoice.get("total_amount") or Decimal("0"),
"currency_code": invoice.get("currency_code", "CAD"),
"service_id": invoice.get("service_id"),
}]
return rows
def _render(errors, line_items=None):
clients, services = _load_clients_services()
if line_items is None:
line_items = _load_invoice_items()
return render_template(
"invoices/edit.html",
invoice=invoice,
clients=clients,
services=services,
errors=errors,
locked=locked,
invoice_items=line_items,
)
if request.method == "POST":
due_at = request.form.get("due_at", "").strip()
notes = request.form.get("notes", "").strip()
@ -4522,15 +4570,29 @@ def edit_invoice(invoice_id):
))
conn.commit()
conn.close()
return redirect("/invoices")
return redirect(f"/invoices/view/{invoice_id}")
client_id = request.form.get("client_id", "").strip()
service_id = request.form.get("service_id", "").strip()
currency_code = request.form.get("currency_code", "").strip()
total_amount = request.form.get("total_amount", "").strip()
currency_code = request.form.get("currency_code", "").strip() or "CAD"
status = request.form.get("status", "").strip()
tax_amount_raw = request.form.get("tax_amount", "0").strip() or "0"
descriptions = request.form.getlist("item_description[]")
quantities = request.form.getlist("item_quantity[]")
unit_amounts = request.form.getlist("item_unit_amount[]")
max_len = max(len(descriptions), len(quantities), len(unit_amounts), 1)
while len(descriptions) < max_len:
descriptions.append("")
while len(quantities) < max_len:
quantities.append("")
while len(unit_amounts) < max_len:
unit_amounts.append("")
errors = []
line_items = []
subtotal_amount = Decimal("0.00000000")
if not client_id:
errors.append("Client is required.")
@ -4538,8 +4600,6 @@ def edit_invoice(invoice_id):
errors.append("Service is required.")
if not currency_code:
errors.append("Currency is required.")
if not total_amount:
errors.append("Total amount is required.")
if not due_at:
errors.append("Due date is required.")
if not status:
@ -4549,38 +4609,86 @@ def edit_invoice(invoice_id):
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)
if amount_value < 0:
errors.append("Total amount cannot be negative.")
except ValueError:
errors.append("Total amount must be a valid number.")
for idx in range(max_len):
desc = (descriptions[idx] or "").strip()
qty_raw = (quantities[idx] or "").strip()
unit_raw = (unit_amounts[idx] or "").strip()
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
if not desc and not qty_raw and not unit_raw:
continue
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
line_no = len(line_items) + 1
if errors:
invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
invoice["currency_code"] = currency_code or invoice["currency_code"]
invoice["total_amount"] = total_amount or invoice["total_amount"]
invoice["due_at"] = due_at or invoice["due_at"]
invoice["status"] = status or invoice["status"]
invoice["notes"] = notes
conn.close()
return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked)
if not desc:
errors.append(f"Line {line_no}: description is required.")
if not qty_raw:
errors.append(f"Line {line_no}: quantity is required.")
if not unit_raw:
errors.append(f"Line {line_no}: unit cost is required.")
cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,))
service_row = cursor.fetchone()
service_name = (service_row or {}).get("service_name") or "Service"
quantity = None
unit_amount = None
line_total = Decimal("0.00000000")
if qty_raw:
try:
quantity = Decimal(qty_raw).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if quantity <= 0:
errors.append(f"Line {line_no}: quantity must be greater than zero.")
except (InvalidOperation, ValueError):
errors.append(f"Line {line_no}: quantity must be a valid number.")
line_description = service_name
if notes:
line_description = f"{service_name} - {notes}"
if unit_raw:
try:
unit_amount = Decimal(unit_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if unit_amount < 0:
errors.append(f"Line {line_no}: unit cost cannot be negative.")
except (InvalidOperation, ValueError):
errors.append(f"Line {line_no}: unit cost must be a valid number.")
if quantity is not None and unit_amount is not None:
line_total = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
line_items.append({
"line_number": line_no,
"description": desc,
"quantity": str(quantity) if quantity is not None else qty_raw,
"unit_amount": str(unit_amount) if unit_amount is not None else unit_raw,
"line_total": str(line_total),
"currency_code": currency_code,
"service_id": service_id,
})
subtotal_amount += line_total
if not line_items:
errors.append("At least one invoice line is required.")
try:
tax_amount = Decimal(tax_amount_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if tax_amount < 0:
errors.append("Tax amount cannot be negative.")
except (InvalidOperation, ValueError):
tax_amount = Decimal("0.00000000")
errors.append("Tax amount must be a valid number.")
subtotal_amount = subtotal_amount.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
invoice["client_id"] = int(client_id) if client_id else invoice["client_id"]
invoice["service_id"] = int(service_id) if service_id else invoice["service_id"]
invoice["currency_code"] = currency_code or invoice["currency_code"]
invoice["subtotal_amount"] = subtotal_amount
invoice["tax_amount"] = tax_amount
invoice["total_amount"] = total_amount
invoice["due_at"] = due_at or invoice["due_at"]
invoice["status"] = status or invoice["status"]
invoice["notes"] = notes
if errors:
rendered = _render(errors, line_items=line_items)
conn.close()
return rendered
update_cursor = conn.cursor()
update_cursor.execute("""
@ -4588,8 +4696,9 @@ def edit_invoice(invoice_id):
SET client_id = %s,
service_id = %s,
currency_code = %s,
total_amount = %s,
subtotal_amount = %s,
tax_amount = %s,
total_amount = %s,
due_at = %s,
status = %s,
notes = %s
@ -4598,8 +4707,9 @@ def edit_invoice(invoice_id):
client_id,
service_id,
currency_code,
total_amount,
total_amount,
str(subtotal_amount),
str(tax_amount),
str(total_amount),
due_at,
status,
notes or None,
@ -4607,46 +4717,40 @@ def edit_invoice(invoice_id):
))
update_cursor.execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_id,))
update_cursor.execute("""
INSERT INTO invoice_items
(
for item in line_items:
update_cursor.execute("""
INSERT INTO invoice_items
(
invoice_id,
line_number,
item_type,
description,
quantity,
unit_amount,
line_total,
currency_code,
service_id
)
VALUES (%s, %s, 'service', %s, %s, %s, %s, %s, %s)
""", (
invoice_id,
line_number,
item_type,
description,
quantity,
unit_amount,
line_total,
item["line_number"],
item["description"],
item["quantity"],
item["unit_amount"],
item["line_total"],
currency_code,
service_id
)
VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s)
""", (
invoice_id,
line_description,
total_amount,
total_amount,
currency_code,
service_id
))
service_id,
))
conn.commit()
conn.close()
return redirect("/invoices")
clients = []
services = []
if not locked:
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
clients = cursor.fetchall()
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name")
services = cursor.fetchall()
return redirect(f"/invoices/view/{invoice_id}")
rendered = _render([])
conn.close()
return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked)
return rendered
@app.route("/payments/export.csv")

172
templates/invoices/edit.html

@ -3,6 +3,7 @@
<head>
<title>Edit Invoice</title>
<style>
body { font-family: Arial, sans-serif; margin: 30px; }
.status-badge {
display: inline-block;
padding: 3px 8px;
@ -22,12 +23,14 @@
.info-box {
border: 1px solid #2563eb;
background: #eff6ff;
color: #10203f;
padding: 10px;
margin-bottom: 15px;
}
.warn-box {
border: 1px solid #aa6600;
background: #fff4dd;
color: #3b2400;
padding: 10px;
margin-bottom: 15px;
}
@ -36,8 +39,18 @@
padding: 10px;
margin-bottom: 15px;
}
.invoice-lines { width: 100%; border-collapse: collapse; margin: 15px 0; }
.invoice-lines th, .invoice-lines td { border: 1px solid #ccc; padding: 8px; vertical-align: top; }
.invoice-lines th { background: #f3f4f6; color: #111827; text-align: left; }
.invoice-lines textarea { width: 100%; min-height: 70px; }
.invoice-lines input[type="number"] { width: 120px; }
.invoice-lines input, .invoice-lines textarea, select { background: #ffffff; color: #111827; }
.summary-box { border:1px solid #ccc; padding:10px; max-width:460px; margin:15px 0; }
.summary-box table { width: 100%; }
.summary-box th { text-align:left; padding-right:20px; }
.remove-line { color: #991b1b; }
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
@ -46,6 +59,7 @@
<p><a href="/">Home</a></p>
<p><a href="/invoices">Back to Invoices</a></p>
<p><a href="/invoices/view/{{ invoice.id }}">View Invoice</a></p>
{% if errors %}
<div class="error-box">
@ -61,14 +75,12 @@
{% if locked %}
<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.
Core accounting fields and line items cannot be changed after payment activity begins. Due date and notes can still be updated.
</div>
{% else %}
<div class="info-box">
<span style="color:#10203f !important;">
<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.
</span>
<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 %}
@ -104,7 +116,7 @@ Service *<br>
<p>
Currency *<br>
<select name="currency_code" required>
<select name="currency_code" id="currency_code" required>
<option value="CAD" {% if invoice.currency_code == 'CAD' %}selected{% endif %}>CAD</option>
<option value="ETHO" {% if invoice.currency_code == 'ETHO' %}selected{% endif %}>ETHO</option>
<option value="EGAZ" {% if invoice.currency_code == 'EGAZ' %}selected{% endif %}>EGAZ</option>
@ -112,10 +124,55 @@ Currency *<br>
</select>
</p>
<p>
Total Amount *<br>
<input type="number" step="0.00000001" min="0" name="total_amount" value="{{ invoice.total_amount }}" required>
</p>
<h2>Invoice Lines</h2>
<table class="invoice-lines">
<thead>
<tr>
<th style="width:50%;">Description *</th>
<th>Qty *</th>
<th>Unit Cost *</th>
<th>Line Total</th>
<th></th>
</tr>
</thead>
<tbody id="invoice-lines-body">
{% set line_items = invoice_items or [{'description':'', 'quantity':'1', 'unit_amount':'', 'line_total':'0'}] %}
{% for item in line_items %}
<tr class="invoice-line">
<td><textarea name="item_description[]" required>{{ item.description }}</textarea></td>
<td><input type="number" step="0.0001" min="0.0001" name="item_quantity[]" class="line-qty" value="{{ item.quantity or '1' }}" required></td>
<td><input type="number" step="0.00000001" min="0" name="item_unit_amount[]" class="line-unit" value="{{ item.unit_amount or '' }}" required></td>
<td class="line-total">0.00 CAD</td>
<td><button type="button" class="remove-line">Remove</button></td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="button" id="add-line">+ Add Line</button></p>
<div class="summary-box">
<strong>Invoice Summary</strong><br><br>
<table>
<tr><th>Subtotal</th><td id="summary_subtotal">0.00 CAD</td></tr>
<tr>
<th>Tax / HST</th>
<td>
<input
type="number"
step="0.00000001"
min="0"
name="tax_amount"
id="tax_amount"
value="{{ invoice.tax_amount or '0' }}"
required
>
<button type="button" id="calc-hst">Set 13% HST</button>
</td>
</tr>
<tr><th>Total Amount</th><td><strong id="summary_total">0.00 CAD</strong></td></tr>
</table>
</div>
{% else %}
<p>Client ID<br><input value="{{ invoice.client_id }}" readonly></p>
<p>Service ID<br><input value="{{ invoice.service_id }}" readonly></p>
@ -146,7 +203,7 @@ Status *<br>
<p>
Notes<br>
<textarea name="notes" rows="5" cols="60">{{ invoice.notes or '' }}</textarea>
<textarea name="notes" rows="7" cols="80">{{ invoice.notes or '' }}</textarea>
</p>
<p>
@ -155,6 +212,97 @@ Notes<br>
</form>
{% if not locked %}
<script>
(function () {
const body = document.getElementById("invoice-lines-body");
const addBtn = document.getElementById("add-line");
const currencySelect = document.getElementById("currency_code");
const subtotalEl = document.getElementById("summary_subtotal");
const taxInput = document.getElementById("tax_amount");
const totalEl = document.getElementById("summary_total");
const hstBtn = document.getElementById("calc-hst");
function decimals() {
return (currencySelect.value || "CAD") === "CAD" ? 2 : 8;
}
function money(value) {
const n = Number(value || 0);
return n.toFixed(decimals()) + " " + (currencySelect.value || "CAD");
}
function subtotalValue() {
let subtotal = 0;
body.querySelectorAll(".invoice-line").forEach(function (row) {
const qty = Number(row.querySelector(".line-qty").value || 0);
const unit = Number(row.querySelector(".line-unit").value || 0);
subtotal += qty * unit;
});
return subtotal;
}
function bindRow(row) {
row.querySelectorAll("input, textarea").forEach(function (el) {
el.addEventListener("input", updateSummary);
el.addEventListener("change", updateSummary);
});
row.querySelector(".remove-line").addEventListener("click", function () {
if (body.querySelectorAll(".invoice-line").length > 1) {
row.remove();
updateSummary();
}
});
}
function updateSummary() {
let subtotal = 0;
body.querySelectorAll(".invoice-line").forEach(function (row) {
const qty = Number(row.querySelector(".line-qty").value || 0);
const unit = Number(row.querySelector(".line-unit").value || 0);
const lineTotal = qty * unit;
subtotal += lineTotal;
row.querySelector(".line-total").textContent = money(lineTotal);
});
const tax = Number(taxInput.value || 0);
subtotalEl.textContent = money(subtotal);
totalEl.textContent = money(subtotal + tax);
}
addBtn.addEventListener("click", function () {
const first = body.querySelector(".invoice-line");
const clone = first.cloneNode(true);
clone.querySelectorAll("textarea").forEach(el => el.value = "");
clone.querySelectorAll("input").forEach(function (el) {
if (el.classList.contains("line-qty")) el.value = "1";
else if (el.classList.contains("line-unit")) el.value = "";
});
clone.querySelector(".line-total").textContent = money(0);
body.appendChild(clone);
bindRow(clone);
updateSummary();
});
hstBtn.addEventListener("click", function () {
const tax = subtotalValue() * 0.13;
taxInput.value = tax.toFixed(8);
updateSummary();
});
body.querySelectorAll(".invoice-line").forEach(bindRow);
currencySelect.addEventListener("change", updateSummary);
taxInput.addEventListener("input", updateSummary);
taxInput.addEventListener("change", updateSummary);
updateSummary();
})();
</script>
{% endif %}
{% include "footer.html" %}
</body>
</html>

Loading…
Cancel
Save