From 19589c40f00c245a5bad2e7155a8833d810622eb Mon Sep 17 00:00:00 2001 From: def Date: Fri, 29 May 2026 01:58:37 +0000 Subject: [PATCH] Bump to v2.0.7 PDF invoice description wrapping --- PROJECT_STATE.md | 8 ++ README.md | 8 ++ VERSION | 2 +- backend/app.py | 186 +++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 174 insertions(+), 30 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index bd3d0f2..6eac7e3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,11 @@ +## 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. +- Updated PDF invoice item line to show subtotal/line amount instead of tax-included total. +- PDF totals now show HST only when tax exists. +- Portal Download All Invoices now creates client-copy PDFs without the optional invoice footer block. +- Kept admin/portal HTML invoice views unchanged. + ## v2.0.6 health revenue dashboard - 2026-05-29 UTC - Added Square / Revenue Health panel to /health. diff --git a/README.md b/README.md index 6f8c758..2682db5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +## 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. +- Updated PDF invoice item line to show subtotal/line amount instead of tax-included total. +- PDF totals now show HST only when tax exists. +- Portal Download All Invoices now creates client-copy PDFs without the optional invoice footer block. +- Kept admin/portal HTML invoice views unchanged. + ## v2.0.6 health revenue dashboard - 2026-05-29 UTC - Added Square / Revenue Health panel to /health. diff --git a/VERSION b/VERSION index cea0e15..d8ba80f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.0.6 +v2.0.7 diff --git a/backend/app.py b/backend/app.py index fa40946..7d3132a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4000,7 +4000,7 @@ def new_invoice(): @app.route("/invoices/pdf/") -def invoice_pdf(invoice_id): +def invoice_pdf(invoice_id, client_copy=False): conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -4047,6 +4047,45 @@ def invoice_pdf(invoice_id): def money(value, currency="CAD"): return f"{to_decimal(value):.2f} {currency}" + def pdf_wrap_text(text, max_width, font="Helvetica", size=10): + text = str(text if text is not None else "-").replace("\r", " ").replace("\n", " ") + words = text.split() + if not words: + return ["-"] + + lines = [] + current = "" + + for word in words: + candidate = (current + " " + word).strip() + if pdf.stringWidth(candidate, font, size) <= max_width: + current = candidate + continue + + if current: + lines.append(current) + current = "" + + if pdf.stringWidth(word, font, size) <= max_width: + current = word + continue + + chunk = "" + for ch in word: + test = chunk + ch + if pdf.stringWidth(test, font, size) <= max_width: + chunk = test + else: + if chunk: + lines.append(chunk) + chunk = ch + current = chunk + + if current: + lines.append(current) + + return lines or ["-"] + pdf.setTitle(f"Invoice {invoice['invoice_number']}") logo_url = (settings.get("business_logo_url") or "").strip() @@ -4121,31 +4160,56 @@ def invoice_pdf(invoice_id): pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") y -= 28 - pdf.setFont("Helvetica-Bold", 11) + pdf.setFont("Helvetica-Bold", 10) pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") + pdf.drawString(125, y, "Service") + pdf.drawString(205, y, "Description") + pdf.drawRightString(right, y, "Amount") y -= 14 pdf.line(left, y, right, y) - y -= 18 + y -= 16 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 + 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 idx in range(row_count): + if y < 120: + pdf.showPage() + y = height - 50 + + 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 -= 13 + + y -= 15 totals_x_label = 360 totals_x_value = right totals = [ ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), + ] + if to_decimal(invoice.get("tax_amount")) > to_decimal("0"): + totals.append(((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD")))) + totals.extend([ ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] + ]) remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid")) @@ -4267,7 +4331,7 @@ def invoice_pdf(invoice_id): y -= 6 - if settings.get("invoice_footer"): + if settings.get("invoice_footer") and not client_copy: y -= 8 pdf.setFont("Helvetica-Bold", 11) pdf.drawString(left, y, "Footer") @@ -5333,7 +5397,7 @@ def portal_download_all_invoices(): with zipfile.ZipFile(memory_file, "w", zipfile.ZIP_DEFLATED) as zf: for inv in invoices: - response = invoice_pdf(inv["id"]) + response = invoice_pdf(inv["id"], client_copy=True) response.direct_passthrough = False pdf_bytes = response.get_data() @@ -5495,6 +5559,45 @@ def portal_invoice_pdf(invoice_id): def money(value, currency="CAD"): return f"{to_decimal(value):.2f} {currency}" + def pdf_wrap_text(text, max_width, font="Helvetica", size=10): + text = str(text if text is not None else "-").replace("\r", " ").replace("\n", " ") + words = text.split() + if not words: + return ["-"] + + lines = [] + current = "" + + for word in words: + candidate = (current + " " + word).strip() + if pdf.stringWidth(candidate, font, size) <= max_width: + current = candidate + continue + + if current: + lines.append(current) + current = "" + + if pdf.stringWidth(word, font, size) <= max_width: + current = word + continue + + chunk = "" + for ch in word: + test = chunk + ch + if pdf.stringWidth(test, font, size) <= max_width: + chunk = test + else: + if chunk: + lines.append(chunk) + chunk = ch + current = chunk + + if current: + lines.append(current) + + return lines or ["-"] + pdf.setTitle(f"Invoice {invoice['invoice_number']}") logo_url = (settings.get("business_logo_url") or "").strip() @@ -5569,31 +5672,56 @@ def portal_invoice_pdf(invoice_id): pdf.drawString(left, y, f"Currency: {invoice.get('currency_code', 'CAD')}") y -= 28 - pdf.setFont("Helvetica-Bold", 11) + pdf.setFont("Helvetica-Bold", 10) pdf.drawString(left, y, "Service Code") - pdf.drawString(180, y, "Service") - pdf.drawString(330, y, "Description") - pdf.drawRightString(right, y, "Total") + pdf.drawString(125, y, "Service") + pdf.drawString(205, y, "Description") + pdf.drawRightString(right, y, "Amount") y -= 14 pdf.line(left, y, right, y) - y -= 18 + y -= 16 - pdf.setFont("Helvetica", 11) - pdf.drawString(left, y, str(invoice.get("service_code") or "-")) - pdf.drawString(180, y, str(invoice.get("service_name") or "-")) - pdf.drawString(330, y, str(invoice.get("notes") or "-")[:28]) - pdf.drawRightString(right, y, money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))) - y -= 28 + 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 idx in range(row_count): + if y < 120: + pdf.showPage() + y = height - 50 + + 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 -= 13 + + y -= 15 totals_x_label = 360 totals_x_value = right totals = [ ("Subtotal", money(invoice.get("subtotal_amount"), invoice.get("currency_code", "CAD"))), - ((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD"))), + ] + if to_decimal(invoice.get("tax_amount")) > to_decimal("0"): + totals.append(((settings.get("tax_label") or "Tax"), money(invoice.get("tax_amount"), invoice.get("currency_code", "CAD")))) + totals.extend([ ("Total", money(invoice.get("total_amount"), invoice.get("currency_code", "CAD"))), ("Paid", money(invoice.get("amount_paid"), invoice.get("currency_code", "CAD"))), - ] + ]) remaining = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))