Skip to content

fix(budget): direct-mode VAT toggle double-applies 19% uplift #1439

@steilerDev

Description

@steilerDev

Problem

When creating a new budget line in direct amount mode and deselecting the "Price includes VAT (19%)" checkbox, the form converts the entered gross-equivalent value to a net value (multiplies by 1.19) before sending it to the server. The server stores plannedAmount as that already-uplifted value AND stores includesVat = false. When consumers (budget overview, work item totals, invoice pipeline, etc.) read the row, the shared helper effectivePlannedAmount() applies another ×1.19 uplift because includesVat === false is the documented signal that the stored amount is net. The result is a budget line whose effective planned amount is uplifted twice (≈41.6% higher than intended).

The same flow triggered from the invoice itemization path (creating a budget line while itemizing an invoice) yields the same wrong stored plannedAmount on the budget line, even though the itemization amount itself is correctly applied to the invoice.

Behaviour must be consistent across all entry points. Deselecting "Price includes VAT" should never produce a double-uplifted planned amount.

Expected vs Actual Behaviour

Entry User enters "Includes VAT" toggle Expected effective planned (gross) Actual effective planned (gross)
Direct amount, new line 100.00 OFF (= net) 119.00 141.61 (100 × 1.19 × 1.19)
Direct amount, new line 100.00 ON (= gross) 100.00 100.00 (correct)
Unit pricing, new line qty=1 × price=100 OFF (= net) 119.00 119.00 (correct)
Invoice itemization, budget line for 100.00 100.00 OFF (= net) 119.00 141.61 (same bug)

The unit-pricing mode is correct because it stores raw qty × unitPrice (net) without applying the multiplier in the hook, so effectivePlannedAmount() correctly applies the single ×1.19 when reading. The direct mode pre-multiplies before submitting, which is the source of the duplication.

Reproduction Steps

  1. Open any work item or household item detail page (e.g. /work-items/<id> or /household-items/<id>).
  2. Click + Add Line in the Budget section.
  3. Keep Direct amount mode selected.
  4. Enter 100.00 in Planned amount.
  5. Uncheck "Price includes VAT (19%)".
  6. Save the line.
  7. Observe: the saved card shows €141.61 as the planned amount (instead of €119.00).
  8. Inspect the row via the API: plannedAmount = 119.00, includesVat = false — both stored values are wrong relative to each other.

Repeat from the invoice itemization flow (invoice detail page → Itemize → add new budget line with VAT off): the budget line is created with the same double-uplift on the stored record.

Acceptance Criteria

Given a user opens the Add Budget Line form in any entry point (work item, household item, invoice itemization)
When they enter a value in Direct amount mode with Includes VAT off
Then the resulting persisted budget line has plannedAmount equal to the value the user typed (net) and includesVat = false, and effectivePlannedAmount() returns exactly value × 1.19 — no double uplift.

Given a user opens the Add Budget Line form in any entry point
When they enter a value in Direct amount mode with Includes VAT on
Then the resulting persisted budget line has plannedAmount equal to the value the user typed (gross) and includesVat = true, and effectivePlannedAmount() returns the typed value unchanged.

Given a user opens the Add Budget Line form in any entry point
When they enter qty and unit price in Unit pricing mode with Includes VAT off
Then the persisted budget line has plannedAmount = qty × unitPrice (net), includesVat = false, and effectivePlannedAmount() returns qty × unitPrice × 1.19 (this is already the correct behaviour — confirm parity is preserved).

Given an existing budget line with the double-uplift bug already persisted
When the user edits it via either the work item, household item, or invoice itemization page
Then the edit form correctly round-trips the stored plannedAmount and includesVat such that re-saving without changes does not further inflate the stored value.

Given the invoice itemization flow
When a user adds a new budget line with Includes VAT off from the invoice detail page
Then the stored budget line has the same plannedAmount and includesVat semantics as a line created directly on a work item — both flows produce equivalent records.

Given/When/Then UAT Scenarios

Scenario 1 — Direct amount, VAT off, work item entry

  • Given an authenticated user on a work item detail page
  • When they add a budget line with planned amount 100.00, direct mode, Includes VAT off
  • Then the budget card displays €119.00 as the effective planned amount (not €141.61)
  • And the stored row has plannedAmount = 100.00 and includesVat = false

Scenario 2 — Direct amount, VAT on, work item entry

  • Given an authenticated user on a work item detail page
  • When they add a budget line with planned amount 100.00, direct mode, Includes VAT on
  • Then the budget card displays €100.00 as the effective planned amount
  • And the stored row has plannedAmount = 100.00 and includesVat = true

Scenario 3 — Direct amount, VAT off, household item entry

  • Given an authenticated user on a household item detail page
  • When they add a budget line with planned amount 50.00, direct mode, Includes VAT off
  • Then the budget card displays €59.50 as the effective planned amount
  • And the work-item-equivalent budget overview totals reflect €59.50, not €70.80

Scenario 4 — Invoice itemization, VAT off

  • Given an invoice exists and a user opens its itemization
  • When they add a new budget line linked to a work item, direct mode, planned 200.00, Includes VAT off
  • Then the new budget line has plannedAmount = 200.00, includesVat = false
  • And the work item's Budget Cost Overview shows €238.00 planned (not €283.22)

Scenario 5 — Round-trip edit

  • Given a budget line created with the corrected behaviour (plannedAmount = 100, includesVat = false)
  • When the user opens the edit form and saves without changes
  • Then the stored values remain plannedAmount = 100, includesVat = false (no inflation)

Relevant Files / Areas

  • client/src/hooks/useBudgetSection.ts (lines 188–213) — the handleSaveBudgetLine function pre-multiplies plannedAmount by 1.19 in direct mode when includesVat = false. This is the bug. Compare with the unit-pricing branch (line 212) which correctly stores raw qty × unitPrice without multiplying.
  • client/src/components/budget/BudgetLineForm.tsx (lines 113–118, 196–211) — the form's "Includes VAT" checkbox lives in both direct and unit modes. The note text shown when VAT is off should not imply the form will pre-apply VAT.
  • shared/src/types/budget.ts (lines 95–108) — effectivePlannedAmount() documents the canonical semantics: plannedAmount is stored as net when includesVat === false, and consumers apply ×1.19 themselves.
  • server/src/services/budgetOverviewService.ts (lines 208–210) — server-side effective(l) mirror of the same semantics. Consumers across the system rely on this; the form is the single deviation.
  • server/src/services/shared/budgetServiceFactory.ts (lines 540–545) — server passes through includesVat and plannedAmount unchanged, so the fix is purely on the client form/hook.
  • server/src/services/invoiceBudgetLineService.ts — invoice-itemization budget-line creation path; needs to use the same fixed value semantics.
  • Test coverage to add: client/src/hooks/useBudgetSection.test.ts to assert direct-mode payload is not pre-multiplied; existing unit-pricing tests should continue to pass.

Notes

  • This is a recent regression: issue Add 'Includes VAT' checkbox to Direct amount mode in budget line editor #1371 (closed 2026-04-28) added the "Includes VAT" checkbox to Direct amount mode. The unit-pricing mode was already correctly handling the semantics; the direct-mode handler was added with the wrong arithmetic.
  • The fix should be on the client (remove the × multiplier line in useBudgetSection.ts direct-mode branch). The server already stores plannedAmount and includesVat faithfully, and downstream consumers (effectivePlannedAmount, budget overview, totals, payback engine) all apply the ×1.19 uplift exactly once based on the includesVat flag.
  • Migration consideration: budget lines already saved with the double-uplift will need either a data fix or user re-entry. The fix story should include a one-time corrective script that finds rows with includes_vat = 0 whose planned_amount shows the ×1.19 fingerprint, OR document that historical rows are user-corrected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions