From cce40d98053a3b5ecc1b20085a77dfcf7642218d Mon Sep 17 00:00:00 2001 From: evilguy4000 Date: Mon, 15 Jun 2026 11:09:53 +0200 Subject: [PATCH 1/3] fix(invoices): link expenses correctly instead of invoice items (#662) Expenses from the Expenses module were easy to mis-add or lose on save because generate-from-time always wiped line items and the Add Expense flow dropped users into the time-entry picker. Preserve items when only expenses are selected, harden edit-time expense sync, and focus the UI on the expenses section with an integration test for the full flow. --- app/routes/invoices.py | 46 ++++++++--- app/templates/invoices/edit.html | 5 +- .../invoices/generate_from_time.html | 64 ++++++++++++++- tests/test_invoices.py | 81 +++++++++++++++++++ 4 files changed, 178 insertions(+), 18 deletions(-) diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 4c922088..ecd10c85 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -451,20 +451,24 @@ def edit_invoice(invoice_id): flash(f"Invalid quantity or price for item {i+1}", "error") continue - # Update expenses - expense_ids = request.form.getlist("expense_id[]") + # Update expenses (only when the expenses section was submitted) + if request.form.get("expenses_sync"): + expense_ids = request.form.getlist("expense_id[]") - # Unlink expenses not in the list - for expense in invoice.expenses.all(): - if str(expense.id) not in expense_ids: - expense.unmark_as_invoiced() + # Unlink expenses removed from this invoice + for expense in invoice.expenses.all(): + if expense.invoice_id == invoice.id and str(expense.id) not in expense_ids: + expense.unmark_as_invoiced() - # Link expenses in the list - if expense_ids: + # Link new expenses from the list for expense_id in expense_ids: try: expense = Expense.query.get(int(expense_id)) - if expense and not expense.invoiced: + if not expense: + continue + if expense.invoice_id == invoice.id: + continue + if not expense.invoiced: expense.mark_as_invoiced(invoice.id) except (ValueError, AttributeError): continue @@ -893,8 +897,9 @@ def generate_from_time(invoice_id): flash(_("No time entries, costs, expenses, or extra goods selected"), "error") return redirect(url_for("invoices.generate_from_time", invoice_id=invoice.id)) - # Clear existing items - invoice.items.delete() + # Only regenerate invoice items when time entries or project costs are selected + if selected_entries or selected_costs: + invoice.items.delete() total_prepaid_allocated = Decimal("0") prepaid_allocator = None @@ -997,6 +1002,8 @@ def generate_from_time(invoice_id): flash(_("Could not generate items due to a database error. Please check server logs."), "error") return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id)) + db.session.refresh(invoice) + # If invoice is already sent (not draft), mark time entries as paid if invoice.status != "draft": from app.services import InvoiceService @@ -1006,7 +1013,20 @@ def generate_from_time(invoice_id): if marked_count > 0: safe_commit("mark_time_entries_paid_from_invoice", {"invoice_id": invoice.id}) - flash(_("Invoice items generated successfully from time entries and costs"), "success") + added_parts = [] + if selected_entries or selected_costs: + added_parts.append(_("time entries and costs")) + if selected_expenses: + added_parts.append(_("expenses")) + if selected_goods: + added_parts.append(_("extra goods")) + if added_parts: + flash( + _("Successfully added %(parts)s to the invoice.", parts=", ".join(added_parts)), + "success", + ) + else: + flash(_("Invoice updated successfully"), "success") if total_prepaid_allocated and total_prepaid_allocated > 0: flash( _( @@ -1022,6 +1042,7 @@ def generate_from_time(invoice_id): from app.services import InvoiceService data = InvoiceService().get_unbilled_data_for_invoice(invoice) + focus = request.args.get("focus", "").strip() return render_template( "invoices/generate_from_time.html", invoice=invoice, @@ -1038,6 +1059,7 @@ def generate_from_time(invoice_id): prepaid_summary=data["prepaid_summary"], prepaid_plan_hours=data["prepaid_plan_hours"], prepaid_reset_day=data.get("prepaid_reset_day"), + focus=focus, ) diff --git a/app/templates/invoices/edit.html b/app/templates/invoices/edit.html index f2320ff4..b50b76da 100644 --- a/app/templates/invoices/edit.html +++ b/app/templates/invoices/edit.html @@ -166,7 +166,8 @@

- {% for expense in invoice.expenses %} + + {% for expense in invoice.expenses.all() %}
@@ -654,7 +655,7 @@

{{ _('Quick Actions') }}

// Add expense button - redirect to generate from time page which includes expenses const addExpenseBtn = document.getElementById('add-expense'); addExpenseBtn && addExpenseBtn.addEventListener('click', function() { - window.location.href = '{{ url_for("invoices.generate_from_time", invoice_id=invoice.id) }}'; + window.location.href = '{{ url_for("invoices.generate_from_time", invoice_id=invoice.id, focus="expenses") }}'; }); // Remove expense handler (unlink from invoice) diff --git a/app/templates/invoices/generate_from_time.html b/app/templates/invoices/generate_from_time.html index f508ca3f..4167703e 100644 --- a/app/templates/invoices/generate_from_time.html +++ b/app/templates/invoices/generate_from_time.html @@ -3,8 +3,20 @@ {% block content %}
-

{{ _('Generate from Time, Costs & Goods') }}

-

{{ _('Select unbilled time entries, project costs, and extra goods to add to this invoice') }}

+

+ {% if focus == 'expenses' %} + {{ _('Add Expenses to Invoice') }} + {% else %} + {{ _('Generate from Time, Costs & Goods') }} + {% endif %} +

+

+ {% if focus == 'expenses' %} + {{ _('Select billable expenses to link to this invoice. They will appear in the Expenses section, not as invoice items.') }} + {% else %} + {{ _('Select unbilled time entries, project costs, and extra goods to add to this invoice') }} + {% endif %} +

{{ _('Back to Edit') }}
@@ -15,6 +27,16 @@

{{ _('Generate from Time, Costs & Goods') }}

+ {% if focus == 'expenses' %} +
+ +
+