fix(booking): explicit Ingen moms in quick review books exempt, not the derived default (#1450)

QuickReviewDialog collapsed 'none' to undefined on the wire, so the server
re-derived the category default and booked 25% moms against an explicit
no-VAT choice while the preview showed no VAT line. 'none' now maps to
'exempt' only when it deviates from the seeded default: untouched bookings
on exempt-default categories (bank fees) stay byte-identical, an explicit
Ingen moms books no VAT line with the classification the momsdeklaration
should see, and the class-2 account auto-'none' stops booking a stray moms
line. Same gap the swedish-compliance bot flagged on #1443.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-07 14:58:46 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 7c93d53fd5
commit 858ad49852
4 changed files with 58 additions and 2 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { resolveQuickReviewDefaults, type ReviewTemplate } from '../quick-review-defaults'
import { resolveQuickReviewDefaults, resolveExplicitVat, type ReviewTemplate } from '../quick-review-defaults'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
/**
@@ -70,3 +70,24 @@ describe('resolveQuickReviewDefaults', () => {
expect(vat === 'none' || typeof vat === 'string').toBe(true)
})
})
describe('resolveExplicitVat', () => {
it('passes explicit rate-bearing treatments through unchanged', () => {
expect(resolveExplicitVat('standard_25', 'standard_25')).toBe('standard_25')
expect(resolveExplicitVat('reduced_12', 'standard_25')).toBe('reduced_12')
expect(resolveExplicitVat('reverse_charge', 'none')).toBe('reverse_charge')
})
it("keeps a seeded 'none' default off the wire (server derives, no VAT line)", () => {
// Bank fees etc.: category default is null -> displayed as 'none'.
// Untouched submit must stay byte-identical to the pre-fix behavior.
expect(resolveExplicitVat('none', 'none')).toBeUndefined()
})
it("sends explicit 'exempt' when the user deviates to Ingen moms", () => {
// The old collapse to undefined made the server re-derive the category
// default and book 25% moms against an explicit no-VAT choice.
expect(resolveExplicitVat('none', 'standard_25')).toBe('exempt')
expect(resolveExplicitVat('none', 'reduced_12')).toBe('exempt')
})
})
+28
View File
@@ -70,3 +70,31 @@ export function resolveQuickReviewDefaults(
return { account, vat }
}
/**
* Resolve the VAT treatment the review dialog should put on the wire.
*
* 'none' is a UI-only sentinel with two meanings that must not be conflated:
*
* - As the SEEDED default (exempt categories like bank fees, or a template
* without a treatment): send nothing and let the server derive, exactly as
* before. The derivation yields no VAT line for those categories, and
* keeping the wire empty preserves byte-identical bookings for the common
* untouched case.
* - As a DEVIATION (the user explicitly picked "Ingen moms" on a category
* whose default carries VAT, or the dialog auto-set 'none' for a class-2
* account override): send 'exempt'. The old collapse to undefined made the
* server re-derive the default, silently booking 25% moms against an
* explicit no-VAT choice, while the preview showed no VAT line. An explicit
* 'exempt' books no VAT line and records the classification the
* momsdeklaration should see (see buildMappingResultFromCategory's note);
* for income it also lands revenue on 3004 instead of a rate-bearing
* account.
*/
export function resolveExplicitVat(
selected: VatTreatment | 'none',
seededDefault: VatTreatment | 'none',
): VatTreatment | undefined {
if (selected !== 'none') return selected
return seededDefault === 'none' ? undefined : 'exempt'
}