From d023c04df2e910b41a5cd4f24f875230094055f9 Mon Sep 17 00:00:00 2001 From: def Date: Mon, 1 Jun 2026 02:55:33 +0000 Subject: [PATCH] Bump to v3.0.1 multi-line invoice edit safety --- PROJECT_STATE.md | 11 ++ README.md | 11 ++ VERSION | 2 +- backend/app.py | 240 +++++++++++++++++++++++++---------- templates/invoices/edit.html | 172 +++++++++++++++++++++++-- 5 files changed, 355 insertions(+), 81 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7348b98..1b05bce 100644 --- a/PROJECT_STATE.md +++ b/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. diff --git a/README.md b/README.md index 8e8469c..e716f9f 100644 --- a/README.md +++ b/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. diff --git a/VERSION b/VERSION index ad55eb8..b105cea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v3.0.0 +v3.0.1 diff --git a/backend/app.py b/backend/app.py index aeec769..f262df5 100644 --- a/backend/app.py +++ b/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") diff --git a/templates/invoices/edit.html b/templates/invoices/edit.html index b5f6650..62f33bf 100644 --- a/templates/invoices/edit.html +++ b/templates/invoices/edit.html @@ -3,6 +3,7 @@ Edit Invoice - + @@ -46,6 +59,7 @@

Home

Back to Invoices

+

View Invoice

{% if errors %}
@@ -61,14 +75,12 @@ {% if locked %}
This invoice is locked for core edits because payments exist.
- 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.
{% else %}
- - Manual status choices are limited to: draft, pending, or cancelled.
- Partial, paid, and overdue are system-managed from payment activity and due dates. -
+ Manual status choices are limited to: draft, pending, or cancelled.
+ Partial, paid, and overdue are system-managed from payment activity and due dates.
{% endif %} @@ -104,7 +116,7 @@ Service *

Currency *
- @@ -112,10 +124,55 @@ Currency *

-

-Total Amount *
- -

+

Invoice Lines

+ + + + + + + + + + + + {% set line_items = invoice_items or [{'description':'', 'quantity':'1', 'unit_amount':'', 'line_total':'0'}] %} + {% for item in line_items %} + + + + + + + + {% endfor %} + +
Description *Qty *Unit Cost *Line Total
0.00 CAD
+ +

+ +
+ Invoice Summary

+ + + + + + + +
Subtotal0.00 CAD
Tax / HST + + +
Total Amount0.00 CAD
+
{% else %}

Client ID

Service ID

@@ -146,7 +203,7 @@ Status *

Notes
- +

@@ -155,6 +212,97 @@ Notes
+{% if not locked %} + +{% endif %} + {% include "footer.html" %}