fix(vat): complete account treatment enforcement (#1593)

* fix(vat): complete account treatment enforcement

* docs(api): refresh account endpoint skill

* fix(mcp): preserve ruta 05 compatibility

* test(vat): seed migration constraint fixtures

* docs(vat): clarify treatment precedence

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-15 23:45:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent edfdbe2d2a
commit 86f0b70fdd
43 changed files with 1017 additions and 359 deletions
+16
View File
@@ -121,6 +121,22 @@ describe('syncMappedAccounts: create pass', () => {
})
})
it('derives the booking rate for a confirmed treatment when the rate is unset', async () => {
const { supabase, inserts } = buildCapturingSupabase()
const result = await run(supabase, [
mapping({
sourceAccount: '4056',
targetAccount: '4056',
sourceName: 'Inköp varor EU',
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: null,
vatTreatmentReviewed: true,
}),
])
expect(result.error).toBeNull()
expect(inserts[0].default_vat_rate).toBe(0.25)
})
it('creates a missing BAS account with the BAS default name when the file has no custom name', async () => {
const { supabase, inserts } = buildCapturingSupabase()
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
applyVatTreatmentReview,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '../account-vat-treatment'
import type { AccountMapping } from '../types'
@@ -32,28 +33,6 @@ describe('enrichAccountMappingsWithVat', () => {
})
})
it('requires review for suggested class 5 and 6 purchase treatments', () => {
const results = enrichAccountMappingsWithVat(
[
mapping('5010', 'Inköp tjänst EU'),
mapping('6010', 'Inköp tjänst utanför EU'),
],
[],
)
expect(results).toEqual([
expect.objectContaining({
defaultVatTreatment: 'reverse_charge_eu_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
expect.objectContaining({
defaultVatTreatment: 'export_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
])
})
it('keeps an existing account treatment without asking again', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('3041', 'Försäljning tjänst 25% sv')],
@@ -70,25 +49,99 @@ describe('enrichAccountMappingsWithVat', () => {
requiresVatTreatmentReview: false,
})
})
it('preserves an existing booking rate when suggesting a missing treatment', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('3041', 'Försäljning tjänst 25% sv')],
[{
account_number: '3041',
default_vat_treatment: null,
default_vat_rate: 0.12,
} as never],
)
expect(result.defaultVatTreatment).toBe('standard_25')
expect(result.defaultVatRate).toBe(0.12)
})
it('requires review for a suggested class 6 service treatment', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('6545', 'Inköp tjänster utanför EU 25%')],
[],
)
expect(result).toMatchObject({
defaultVatTreatment: 'reverse_charge_non_eu_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
})
})
})
describe('applyVatTreatmentReview', () => {
it('marks only the selected row reviewed, including class 5 and 6 accounts', () => {
const mappings = [
mapping('5010', 'Inköp tjänst EU'),
mapping('3041', 'Försäljning tjänst 25% sv'),
]
const result = applyVatTreatmentReview(
it('persists a suggestion only after an explicit row confirmation', () => {
const mappings = enrichAccountMappingsWithVat([
mapping('4056', 'Inköp varor 25% EU'),
], [])
expect(mappings[0].vatTreatmentReviewed).toBe(false)
const reviewed = applyVatTreatmentReview(
mappings,
'5010',
'reverse_charge_eu_services',
0.25,
'4056',
mappings[0].defaultVatTreatment ?? null,
mappings[0].defaultVatRate ?? null,
)
expect(result[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_services',
defaultVatRate: 0.25,
expect(reviewed[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
vatTreatmentReviewed: true,
vatTreatmentSuggested: false,
})
})
it('clears hidden review state on remap and restores it on identity mapping', () => {
const [suggested] = enrichAccountMappingsWithVat([
mapping('4056', 'Inköp varor 25% EU'),
], [])
const [remapped] = enrichAccountMappingsWithVat([{
...suggested,
targetAccount: '4010',
targetName: 'Inköp material',
}], [])
expect(remapped).toMatchObject({
defaultVatTreatment: null,
requiresVatTreatmentReview: false,
vatTreatmentReviewed: true,
})
const [identity] = enrichAccountMappingsWithVat([{
...remapped,
targetAccount: '4056',
targetName: 'Inköp varor 25% EU',
}], [])
expect(identity).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
})
})
it('preserves another row review when one mapping changes', () => {
const initial = enrichAccountMappingsWithVat([
mapping('3041', 'Försäljning tjänst 25% sv'),
mapping('4056', 'Inköp varor 25% EU'),
], [])
const reviewed = applyVatTreatmentReview(initial, '3041', 'exempt', 0)
const remapped = enrichChangedAccountMappingWithVat(
reviewed.map((item) => item.sourceAccount === '4056'
? { ...item, targetAccount: '4010', targetName: 'Inköp material' }
: item),
'4056',
[],
)
expect(remapped[0]).toMatchObject({
defaultVatTreatment: 'exempt',
defaultVatRate: 0,
vatTreatmentReviewed: true,
})
expect(result[1].vatTreatmentReviewed).toBeUndefined()
})
})
+17 -2
View File
@@ -14,7 +14,12 @@ import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { AccountMapping } from './types'
import { isAccountVatTreatment, type AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import {
defaultRateForVatTreatment,
isAccountVatTreatment,
isVatTreatmentAllowedForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
export interface AccountSyncResult {
/** Accounts inserted into chart_of_accounts */
@@ -164,9 +169,19 @@ export async function syncMappedAccounts(
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
return result
}
const accountClass = Number(mapping.targetAccount.charAt(0))
if (
mapping.defaultVatTreatment &&
!isVatTreatmentAllowedForAccountClass(mapping.defaultVatTreatment, accountClass)
) {
result.error = `VAT treatment is not valid for account ${mapping.sourceAccount}`
return result
}
vatDefaults.set(mapping.targetAccount, {
treatment: mapping.defaultVatTreatment ?? null,
rate: mapping.defaultVatRate ?? null,
rate: mapping.defaultVatTreatment && mapping.defaultVatRate == null
? defaultRateForVatTreatment(mapping.defaultVatTreatment, accountClass)
: mapping.defaultVatRate ?? null,
})
}
+24 -3
View File
@@ -19,7 +19,16 @@ export function enrichAccountMappingsWithVat(
)
return mappings.map((mapping) => {
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) {
return {
...mapping,
defaultVatTreatment: null,
defaultVatRate: null,
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
requiresVatTreatmentReview: false,
}
}
const accountClass = Number(mapping.sourceAccount.charAt(0))
if (accountClass < 3 || accountClass > 6) return mapping
@@ -39,10 +48,10 @@ export function enrichAccountMappingsWithVat(
return {
...mapping,
defaultVatTreatment: suggestion?.treatment ?? null,
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
defaultVatRate: existing?.default_vat_rate ?? suggestion?.rate ?? null,
vatTreatmentSuggested: Boolean(suggestion),
vatTreatmentReviewed: false,
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
requiresVatTreatmentReview: accountClass === 3 || accountClass === 4 || Boolean(suggestion),
}
})
}
@@ -65,3 +74,15 @@ export function applyVatTreatmentReview(
: mapping
)
}
export function enrichChangedAccountMappingWithVat(
mappings: AccountMapping[],
sourceAccount: string,
existingAccounts: BASAccount[],
): AccountMapping[] {
return mappings.map((mapping) =>
mapping.sourceAccount === sourceAccount
? enrichAccountMappingsWithVat([mapping], existingAccounts)[0]
: mapping
)
}