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
+
-
-
-
+
-
+
Invoice Summary
-
- | Subtotal |
- 0.00 CAD |
-
-
- | HST 13% |
- 0.00 CAD |
-
-
- | Total Amount |
- 0.00 CAD |
-
+ | Subtotal | 0.00 CAD |
+ | HST 13% | 0.00 CAD |
+ | Total Amount | 0.00 CAD |
@@ -127,47 +119,95 @@ Due Date *
-
-
-
+