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/
-Description *
-
-
-Qty *
-
-
-Unit Cost *
-
-
| Description * | +Qty * | +Unit Cost * | +HST | +Line Total | ++ |
|---|---|---|---|---|---|
| + | + | + | + + + | +0.00 CAD | ++ |
- -
+ -| Subtotal | -0.00 CAD | -
|---|---|
| Total Amount | -0.00 CAD | -
| Subtotal | 0.00 CAD |
| Total Amount | 0.00 CAD |
- -
+