From 26fbb6a4ff159fcee8aad459d32a08d43dad5d65 Mon Sep 17 00:00:00 2001 From: def Date: Fri, 29 May 2026 03:31:39 +0000 Subject: [PATCH] Bump to v2.0.8 multi-line invoice creation --- PROJECT_STATE.md | 12 ++ README.md | 12 ++ VERSION | 2 +- backend/app.py | 361 +++++++++++++++++++++++------------ templates/invoices/new.html | 192 +++++++++++-------- templates/invoices/view.html | 15 ++ 6 files changed, 392 insertions(+), 202 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6eac7e3..c65934c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,15 @@ +## v2.0.8 multi-line invoice creation - 2026-05-29 UTC + +- Added multi-line invoice item creation on the admin Create Invoice page. +- Added “+ Add Line” support with per-line description, quantity, unit cost, and HST checkbox. +- Invoice subtotal is now calculated from the sum of all line totals. +- HST is calculated from taxable lines only. +- Invoice creation now inserts one invoice_items row per invoice line. +- Admin invoice view now displays all invoice line items with quantity, unit cost, and amount. +- Invoice PDFs now render all invoice line items instead of only the invoice-level notes field. +- Existing single-line invoices remain compatible. +- Fixed Create Invoice table header/input styling for dark theme readability. + ## v2.0.7 PDF invoice description wrapping - 2026-05-29 UTC - Fixed invoice PDFs so long invoice descriptions wrap across multiple lines instead of being truncated. diff --git a/README.md b/README.md index 2682db5..424fedb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ +## v2.0.8 multi-line invoice creation - 2026-05-29 UTC + +- Added multi-line invoice item creation on the admin Create Invoice page. +- Added “+ Add Line” support with per-line description, quantity, unit cost, and HST checkbox. +- Invoice subtotal is now calculated from the sum of all line totals. +- HST is calculated from taxable lines only. +- Invoice creation now inserts one invoice_items row per invoice line. +- Admin invoice view now displays all invoice line items with quantity, unit cost, and amount. +- Invoice PDFs now render all invoice line items instead of only the invoice-level notes field. +- Existing single-line invoices remain compatible. +- Fixed Create Invoice table header/input styling for dark theme readability. + ## v2.0.7 PDF invoice description wrapping - 2026-05-29 UTC - Fixed invoice PDFs so long invoice descriptions wrap across multiple lines instead of being truncated. diff --git a/VERSION b/VERSION index d8ba80f..923fd4d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.0.7 +v2.0.8 diff --git a/backend/app.py b/backend/app.py index 7d3132a..a2ea46e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3811,28 +3811,51 @@ def generate_next_invoice_number(cursor, prefix="INV"): next_num += 1 + @app.route("/invoices/new", methods=["GET", "POST"]) def new_invoice(): gate = admin_required() if gate: return gate + ensure_invoice_quote_columns() conn = get_db_connection() cursor = conn.cursor(dictionary=True) + def _load_invoice_form_lists(): + 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 + if request.method == "POST": from decimal import Decimal, InvalidOperation, ROUND_HALF_UP client_id = request.form.get("client_id", "").strip() service_id = request.form.get("service_id", "").strip() - currency_code = request.form.get("currency_code", "").strip() - quantity_raw = request.form.get("quantity", "1").strip() - unit_amount_raw = request.form.get("unit_amount", "").strip() + currency_code = request.form.get("currency_code", "").strip() or "CAD" due_at = request.form.get("due_at", "").strip() - line_description = request.form.get("description", "").strip() - apply_tax = request.form.get("apply_tax") == "1" + + descriptions = request.form.getlist("item_description[]") + quantities = request.form.getlist("item_quantity[]") + unit_amounts = request.form.getlist("item_unit_amount[]") + taxables = request.form.getlist("item_taxable[]") + + max_len = max(len(descriptions), len(quantities), len(unit_amounts), len(taxables), 1) + while len(descriptions) < max_len: + descriptions.append("") + while len(quantities) < max_len: + quantities.append("") + while len(unit_amounts) < max_len: + unit_amounts.append("") + while len(taxables) < max_len: + taxables.append("0") errors = [] + line_items = [] if not client_id: errors.append("Client is required.") @@ -3840,68 +3863,92 @@ def new_invoice(): errors.append("Service is required.") if not currency_code: errors.append("Currency is required.") - if not quantity_raw: - errors.append("Quantity is required.") - if not unit_amount_raw: - errors.append("Unit cost is required.") if not due_at: errors.append("Due date is required.") - if not line_description: - errors.append("Description is required.") - quantity = None - unit_amount = None - subtotal_amount = None + subtotal_amount = Decimal("0.00000000") tax_amount = Decimal("0.00000000") - total_amount = None - if not errors: - try: - quantity = Decimal(quantity_raw).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) - if quantity <= 0: - errors.append("Quantity must be greater than zero.") - except (InvalidOperation, ValueError): - errors.append("Quantity 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() + taxable = str(taxables[idx] or "0").strip() == "1" - try: - unit_amount = Decimal(unit_amount_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) - if unit_amount <= 0: - errors.append("Unit cost must be greater than zero.") - except (InvalidOperation, ValueError): - errors.append("Unit cost must be a valid number.") + if not desc and not qty_raw and not unit_raw: + continue - if not errors: - subtotal_amount = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) - if apply_tax: - tax_amount = (subtotal_amount * Decimal("0.13")).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) - total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + line_no = len(line_items) + 1 - if errors: - cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") - clients = cursor.fetchall() + 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 id, service_code, service_name FROM services ORDER BY service_name") - services = cursor.fetchall() + quantity = None + unit_amount = None - conn.close() + 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.") - form_data = { - "client_id": client_id, - "service_id": service_id, - "currency_code": currency_code, - "quantity": quantity_raw, - "unit_amount": unit_amount_raw, - "due_at": due_at, - "description": line_description, - "apply_tax": "1" if apply_tax else "", - } + 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 must be greater than zero.") + except (InvalidOperation, ValueError): + errors.append(f"Line {line_no}: unit cost must be a valid number.") + + line_total = Decimal("0.00000000") + line_tax = Decimal("0.00000000") + + if quantity is not None and unit_amount is not None: + line_total = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + if taxable: + line_tax = (line_total * Decimal("0.13")).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + + line_items.append({ + "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), + "taxable": "1" if taxable else "0", + "line_tax": str(line_tax), + }) + + subtotal_amount += line_total + tax_amount += line_tax + + if not line_items: + errors.append("At least one invoice line is required.") + + subtotal_amount = subtotal_amount.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + tax_amount = tax_amount.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + + if errors: + clients, services = _load_invoice_form_lists() + conn.close() return render_template( "invoices/new.html", clients=clients, services=services, errors=errors, - form_data=form_data, + form_data={ + "client_id": client_id, + "service_id": service_id, + "currency_code": currency_code, + "due_at": due_at, + "line_items": line_items, + }, ) oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, str(total_amount)) @@ -3912,6 +3959,7 @@ def new_invoice(): insert_cursor = conn.cursor() invoice_number = generate_next_invoice_number(insert_cursor) + notes = "\n".join([item["description"] for item in line_items]).strip() insert_cursor.execute(""" INSERT INTO invoices @@ -3942,7 +3990,7 @@ def new_invoice(): subtotal_amount, tax_amount, due_at, - line_description, + notes, quote_fiat_amount, quote_fiat_currency, quote_expires_at, @@ -3951,41 +3999,38 @@ def new_invoice(): invoice_id = insert_cursor.lastrowid - insert_cursor.execute(""" - INSERT INTO invoice_items - ( + for line_number, item in enumerate(line_items, start=1): + insert_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["description"], + item["quantity"], + item["unit_amount"], + item["line_total"], currency_code, service_id - ) - VALUES (%s, 1, 'service', %s, %s, %s, %s, %s, %s) - """, ( - invoice_id, - line_description, - quantity, - unit_amount, - subtotal_amount, - currency_code, - service_id - )) + )) conn.commit() conn.close() - return redirect("/invoices") - - 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}") + clients, services = _load_invoice_form_lists() conn.close() return render_template( @@ -3993,12 +4038,15 @@ def new_invoice(): clients=clients, services=services, errors=[], - form_data={}, + form_data={ + "currency_code": "CAD", + "line_items": [ + {"description": "", "quantity": "1", "unit_amount": "", "taxable": "0"} + ], + }, ) - - @app.route("/invoices/pdf/") def invoice_pdf(invoice_id, client_copy=False): conn = get_db_connection() @@ -4025,6 +4073,24 @@ def invoice_pdf(invoice_id, client_copy=False): conn.close() return "Invoice not found", 404 + cursor.execute(""" + SELECT line_number, description, quantity, unit_amount, line_total, currency_code + FROM invoice_items + WHERE invoice_id = %s + ORDER BY line_number ASC, id ASC + """, (invoice_id,)) + invoice_items = cursor.fetchall() + + if not invoice_items: + invoice_items = [{ + "line_number": 1, + "description": invoice.get("notes") or "-", + "quantity": 1, + "unit_amount": invoice.get("subtotal_amount"), + "line_total": invoice.get("subtotal_amount"), + "currency_code": invoice.get("currency_code", "CAD"), + }] + conn.close() settings = get_app_settings() @@ -4170,33 +4236,37 @@ def invoice_pdf(invoice_id, client_copy=False): y -= 16 pdf.setFont("Helvetica", 10) - service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10) - service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10) - description_lines = pdf_wrap_text(invoice.get("notes") or "-", 265, "Helvetica", 10) - row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1) - line_amount = invoice.get("subtotal_amount") - if line_amount is None: - line_amount = invoice.get("total_amount") + for item in invoice_items: + service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10) + service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10) + description_lines = pdf_wrap_text(item.get("description") or "-", 265, "Helvetica", 10) + row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1) - for idx in range(row_count): - if y < 120: - pdf.showPage() - y = height - 50 + line_amount = item.get("line_total") + if line_amount is None: + line_amount = invoice.get("subtotal_amount") - pdf.setFont("Helvetica", 10) - if idx < len(service_code_lines): - pdf.drawString(left, y, service_code_lines[idx]) - if idx < len(service_lines): - pdf.drawString(125, y, service_lines[idx]) - if idx < len(description_lines): - pdf.drawString(205, y, description_lines[idx]) - if idx == 0: - pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD"))) + for idx in range(row_count): + if y < 120: + pdf.showPage() + y = height - 50 - y -= 13 + pdf.setFont("Helvetica", 10) + if idx < len(service_code_lines): + pdf.drawString(left, y, service_code_lines[idx]) + if idx < len(service_lines): + pdf.drawString(125, y, service_lines[idx]) + if idx < len(description_lines): + pdf.drawString(205, y, description_lines[idx]) + if idx == 0: + pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD"))) - y -= 15 + y -= 13 + + y -= 6 + + y -= 9 totals_x_label = 360 totals_x_value = right @@ -4380,6 +4450,24 @@ def view_invoice(invoice_id): conn.close() return "Invoice not found", 404 + cursor.execute(""" + SELECT line_number, description, quantity, unit_amount, line_total, currency_code + FROM invoice_items + WHERE invoice_id = %s + ORDER BY line_number ASC, id ASC + """, (invoice_id,)) + invoice_items = cursor.fetchall() + + if not invoice_items: + invoice_items = [{ + "line_number": 1, + "description": invoice.get("notes") or "-", + "quantity": 1, + "unit_amount": invoice.get("subtotal_amount"), + "line_total": invoice.get("subtotal_amount"), + "currency_code": invoice.get("currency_code", "CAD"), + }] + conn.close() settings = get_app_settings() invoice_payments = get_invoice_payments(invoice_id) @@ -4387,7 +4475,8 @@ def view_invoice(invoice_id): "invoices/view.html", invoice=invoice, settings=settings, - invoice_payments=invoice_payments + invoice_payments=invoice_payments, + invoice_items=invoice_items ) @@ -5538,6 +5627,24 @@ def portal_invoice_pdf(invoice_id): conn.close() return redirect("/portal/dashboard") + cursor.execute(""" + SELECT line_number, description, quantity, unit_amount, line_total, currency_code + FROM invoice_items + WHERE invoice_id = %s + ORDER BY line_number ASC, id ASC + """, (invoice_id,)) + invoice_items = cursor.fetchall() + + if not invoice_items: + invoice_items = [{ + "line_number": 1, + "description": invoice.get("notes") or "-", + "quantity": 1, + "unit_amount": invoice.get("subtotal_amount"), + "line_total": invoice.get("subtotal_amount"), + "currency_code": invoice.get("currency_code", "CAD"), + }] + conn.close() settings = get_app_settings() @@ -5682,33 +5789,37 @@ def portal_invoice_pdf(invoice_id): y -= 16 pdf.setFont("Helvetica", 10) - service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10) - service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10) - description_lines = pdf_wrap_text(invoice.get("notes") or "-", 265, "Helvetica", 10) - row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1) - line_amount = invoice.get("subtotal_amount") - if line_amount is None: - line_amount = invoice.get("total_amount") + for item in invoice_items: + service_code_lines = pdf_wrap_text(invoice.get("service_code") or "-", 65, "Helvetica", 10) + service_lines = pdf_wrap_text(invoice.get("service_name") or "-", 70, "Helvetica", 10) + description_lines = pdf_wrap_text(item.get("description") or "-", 265, "Helvetica", 10) + row_count = max(len(service_code_lines), len(service_lines), len(description_lines), 1) - for idx in range(row_count): - if y < 120: - pdf.showPage() - y = height - 50 + line_amount = item.get("line_total") + if line_amount is None: + line_amount = invoice.get("subtotal_amount") - pdf.setFont("Helvetica", 10) - if idx < len(service_code_lines): - pdf.drawString(left, y, service_code_lines[idx]) - if idx < len(service_lines): - pdf.drawString(125, y, service_lines[idx]) - if idx < len(description_lines): - pdf.drawString(205, y, description_lines[idx]) - if idx == 0: - pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD"))) + for idx in range(row_count): + if y < 120: + pdf.showPage() + y = height - 50 - y -= 13 + pdf.setFont("Helvetica", 10) + if idx < len(service_code_lines): + pdf.drawString(left, y, service_code_lines[idx]) + if idx < len(service_lines): + pdf.drawString(125, y, service_lines[idx]) + if idx < len(description_lines): + pdf.drawString(205, y, description_lines[idx]) + if idx == 0: + pdf.drawRightString(right, y, money(line_amount, invoice.get("currency_code", "CAD"))) - y -= 15 + y -= 13 + + y -= 6 + + y -= 9 totals_x_label = 360 totals_x_value = right diff --git a/templates/invoices/new.html b/templates/invoices/new.html index 173655c..cf7b268 100644 --- a/templates/invoices/new.html +++ b/templates/invoices/new.html @@ -2,7 +2,20 @@ New Invoice - + + @@ -60,65 +73,44 @@ Currency *

-

-Description *
- -

- -

-Qty *
- -

- -

-Unit Cost *
- -

+

Invoice Lines

+ + + + + + + + + + + + + {% set line_items = form_data.get('line_items') or [{'description':'', 'quantity':'1', 'unit_amount':'', 'taxable':'0'}] %} + {% for item in line_items %} + + + + + + + + + {% endfor %} + +
Description *Qty *Unit Cost *HSTLine Total
+ + + 0.00 CAD
-

- -

+

-
+
Invoice Summary

- - - - - - - - - - - - + + +
Subtotal0.00 CAD
Total Amount0.00 CAD
Subtotal0.00 CAD
Total Amount0.00 CAD
@@ -127,47 +119,95 @@ Due Date *

-

- -

+