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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
7c93d53fd5
commit
858ad49852
@@ -825,3 +825,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-06] SIE-export period default left unchanged despite the choice-audit finding: FiscalYearSelector with includeAllOption=false already auto-selects the newest started period once loaded, so the "opens with nothing selected" claim is only fetch latency. FyPicker gained preferLatestEnded for helårsmoms instead, which also skips the shared per-company localStorage scope on that surface: a filing page defaulting to the current (unfilable) year because Balansräkningen was last viewed there is the one wrong default.
|
||||
[2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended.
|
||||
[2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run.
|
||||
[2026-08-07] QuickReviewDialog's 'none'-collapse closed with a deviation test, not a blanket map: 'none' as the SEEDED default (exempt categories) stays off the wire so untouched bank-fee bookings remain byte-identical, while 'none' as a user deviation goes as explicit 'exempt' (books no VAT line, records the classification the momsdeklaration should see, lands income on 3004). The batch dialog's blanket 'none'→'exempt' map from #1443 is correct THERE because its select has no seeded-per-category default. CategoryExpandedDialog carries the same collapse but has zero references (dead code): left untouched, deletion is hygiene for a separate sweep.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
||||
import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults'
|
||||
import { resolveExplicitVat } from '@/lib/transactions/quick-review-defaults'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
@@ -250,7 +251,12 @@ export default function QuickReviewDialog({
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
|
||||
// 'none' as the seeded default stays off the wire (server derives, no
|
||||
// VAT line); 'none' as a user deviation goes as explicit 'exempt'. The
|
||||
// old unconditional collapse re-derived the default server-side and
|
||||
// booked 25% moms against an explicit "Ingen moms" while the preview
|
||||
// showed none. See resolveExplicitVat.
|
||||
const resolvedVat = resolveExplicitVat(vatTreatment, defaultVat)
|
||||
const catDefault = getDefaultAccountForCategory(category)
|
||||
const override = accountOverride && accountOverride !== catDefault
|
||||
? accountOverride
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user