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:
co-authored by
Claude Fable 5
parent
edfdbe2d2a
commit
86f0b70fdd
@@ -65,6 +65,7 @@ import type {
|
||||
} from '@/lib/import/types'
|
||||
import {
|
||||
applyVatTreatmentReview,
|
||||
enrichChangedAccountMappingWithVat,
|
||||
enrichAccountMappingsWithVat,
|
||||
} from '@/lib/import/account-vat-treatment'
|
||||
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
|
||||
@@ -604,15 +605,14 @@ function SIEImportWizard() {
|
||||
setIssues(data.parsed.issues)
|
||||
setSieAccounts(data.parsed.accounts)
|
||||
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
|
||||
} else {
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, []))
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
if (!accountsRes.ok) {
|
||||
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
||||
}
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
|
||||
|
||||
setStep('preview')
|
||||
|
||||
@@ -704,11 +704,19 @@ function SIEImportWizard() {
|
||||
}, [file, handleFileSelect, toast])
|
||||
|
||||
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
|
||||
setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName))
|
||||
setMappings((prev) => enrichChangedAccountMappingWithVat(
|
||||
applyMappingOverride(prev, sourceAccount, targetAccount, targetName),
|
||||
sourceAccount,
|
||||
basAccounts,
|
||||
))
|
||||
|
||||
setPreview((prev) => {
|
||||
if (!prev) return prev
|
||||
const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName)
|
||||
const updatedMappings = enrichChangedAccountMappingWithVat(
|
||||
applyMappingOverride(mappings, sourceAccount, targetAccount, targetName),
|
||||
sourceAccount,
|
||||
basAccounts,
|
||||
)
|
||||
const mapped = updatedMappings.filter((m) => m.targetAccount).length
|
||||
const unmapped = updatedMappings.length - mapped
|
||||
const lowConfidence = updatedMappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
|
||||
@@ -723,7 +731,7 @@ function SIEImportWizard() {
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [mappings])
|
||||
}, [basAccounts, mappings])
|
||||
|
||||
const handleVatTreatmentChange = useCallback((
|
||||
sourceAccount: string,
|
||||
@@ -771,13 +779,25 @@ function SIEImportWizard() {
|
||||
|
||||
toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` })
|
||||
|
||||
// Optimistically update mappings: mark created accounts as self-mapped
|
||||
const createdSet = new Set(missingAccounts.map(a => a.number))
|
||||
setMappings(prev => enrichAccountMappingsWithVat(prev.map(m =>
|
||||
!m.targetAccount && createdSet.has(m.sourceAccount)
|
||||
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
|
||||
: m
|
||||
), basAccounts))
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
if (!accountsRes.ok) {
|
||||
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
||||
}
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(prev => {
|
||||
let updated = prev.map(m =>
|
||||
!m.targetAccount && createdSet.has(m.sourceAccount)
|
||||
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
|
||||
: m
|
||||
)
|
||||
for (const sourceAccount of createdSet) {
|
||||
updated = enrichChangedAccountMappingWithVat(updated, sourceAccount, accounts)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
setPreview(prev => {
|
||||
if (!prev) return prev
|
||||
const newMapped = prev.mappingStatus.mapped + createdSet.size
|
||||
@@ -790,21 +810,12 @@ function SIEImportWizard() {
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Also refresh BAS accounts list
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(prev => enrichAccountMappingsWithVat(prev, accounts))
|
||||
}
|
||||
} catch (err) {
|
||||
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCreatingAccounts(false)
|
||||
}
|
||||
}, [basAccounts, missingAccounts, toast])
|
||||
}, [missingAccounts, toast])
|
||||
|
||||
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
|
||||
if (!file) { setError('No file selected'); return }
|
||||
@@ -941,8 +952,8 @@ function SIEImportWizard() {
|
||||
preview={preview} theaterModel={theaterModel}
|
||||
unresolvedVatAccountCount={mappings.filter((mapping) =>
|
||||
mapping.sourceAccount === mapping.targetAccount &&
|
||||
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) &&
|
||||
!mapping.vatTreatmentReviewed
|
||||
['3', '4'].includes(mapping.sourceAccount.charAt(0)) &&
|
||||
!mapping.defaultVatTreatment
|
||||
).length} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { sparsePatchBody } from '@/lib/api/sparse-patch'
|
||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// DELETE hard-deletes an unused, non-system account; accounts referenced by
|
||||
// this company's journal entries must be deactivated instead (PUT is_active).
|
||||
@@ -104,19 +107,44 @@ export const PUT = withRouteContext(
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const accountClass = parseInt(number[0])
|
||||
|
||||
if (
|
||||
body.default_vat_treatment &&
|
||||
!isVatTreatmentValidForAccountClass(
|
||||
body.default_vat_treatment,
|
||||
Number(number.charAt(0)),
|
||||
)
|
||||
!isVatTreatmentAllowedForAccountClass(body.default_vat_treatment, accountClass)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momshanteringen är inte giltig för kontoklassen' },
|
||||
{ error: 'Momskoden kan inte användas för den här kontoklassen.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (body.default_vat_treatment && body.default_vat_rate === undefined) {
|
||||
const { data: current, error: currentError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('default_vat_rate')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
.single()
|
||||
|
||||
if (currentError) {
|
||||
if (currentError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: getUserErrorMessage(currentError) }, { status: 500 })
|
||||
}
|
||||
|
||||
if (current.default_vat_rate == null) {
|
||||
body.default_vat_rate = defaultRateForVatTreatment(
|
||||
body.default_vat_treatment,
|
||||
accountClass,
|
||||
)
|
||||
}
|
||||
} else if (body.default_vat_treatment && body.default_vat_rate === null) {
|
||||
body.default_vat_rate = defaultRateForVatTreatment(
|
||||
body.default_vat_treatment,
|
||||
accountClass,
|
||||
)
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
|
||||
|
||||
@@ -289,6 +289,42 @@ describe('POST /api/bookkeeping/accounts', () => {
|
||||
}
|
||||
expect(insertArg.default_vat_treatment).toBe('reverse_charge_eu_goods')
|
||||
})
|
||||
|
||||
it('derives the booking rate when a treatment is set without one', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: { account_number: '4056' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account_number: '4056',
|
||||
account_name: 'Inköp varor EU',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
},
|
||||
})
|
||||
expect((await createPOST(req, routeParams)).status).toBe(200)
|
||||
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
|
||||
default_vat_rate?: number | null
|
||||
}
|
||||
expect(insertArg.default_vat_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
it('rejects a treatment that cannot apply to the account class', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account_number: '4056',
|
||||
account_name: 'Inköp varor EU',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'standard_25',
|
||||
},
|
||||
})
|
||||
expect((await createPOST(req, routeParams)).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
|
||||
@@ -398,10 +434,11 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
||||
expect(updateArg?.default_vat_rate).toBe(0)
|
||||
})
|
||||
|
||||
it('forwards default_vat_treatment into the update', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([{
|
||||
data: { account_number: '3041', default_vat_treatment: 'standard_25' },
|
||||
}])
|
||||
it('preserves an existing booking rate when updating only the treatment', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { default_vat_rate: 0.12 } },
|
||||
{ data: { account_number: '3041', default_vat_treatment: 'standard_25' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/3041', {
|
||||
method: 'PUT',
|
||||
@@ -410,19 +447,34 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
||||
expect((await PUT(req, { params: Promise.resolve({ number: '3041' }) })).status).toBe(200)
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
||||
default_vat_treatment?: string | null
|
||||
default_vat_rate?: number | null
|
||||
}
|
||||
expect(updateArg.default_vat_treatment).toBe('standard_25')
|
||||
expect(updateArg.default_vat_rate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a VAT treatment that does not apply to the account class', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([])
|
||||
it('derives the booking rate when treatment is updated and the stored rate is unset', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { default_vat_rate: null } },
|
||||
{
|
||||
data: {
|
||||
account_number: '4056',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
default_vat_rate: 0.25,
|
||||
},
|
||||
},
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/4056', {
|
||||
method: 'PUT',
|
||||
body: { default_vat_treatment: 'standard_25' },
|
||||
body: { default_vat_treatment: 'reverse_charge_eu_goods' },
|
||||
})
|
||||
expect((await PUT(req, numberParams)).status).toBe(400)
|
||||
expect(calls.some((call) => call.method === 'update')).toBe(false)
|
||||
|
||||
expect((await PUT(req, { params: Promise.resolve({ number: '4056' }) })).status).toBe(200)
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
||||
default_vat_rate?: number | null
|
||||
}
|
||||
expect(updateArg.default_vat_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
// The body is spread straight into .update(), so the write set must be
|
||||
|
||||
@@ -6,6 +6,10 @@ import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { CreateAccountSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
|
||||
// (import, supplier-invoices, article form) consume the list directly.
|
||||
@@ -87,6 +91,19 @@ export const POST = withRouteContext(
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const accountClass = parseInt(body.account_number[0])
|
||||
if (
|
||||
body.default_vat_treatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(body.default_vat_treatment, accountClass)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momskoden kan inte användas för den här kontoklassen.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const defaultVatRate = body.default_vat_treatment && body.default_vat_rate == null
|
||||
? defaultRateForVatTreatment(body.default_vat_treatment, accountClass)
|
||||
: body.default_vat_rate ?? null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
@@ -95,7 +112,7 @@ export const POST = withRouteContext(
|
||||
company_id: companyId,
|
||||
account_number: body.account_number,
|
||||
account_name: body.account_name,
|
||||
account_class: parseInt(body.account_number[0]),
|
||||
account_class: accountClass,
|
||||
account_group: body.account_number.substring(0, 2),
|
||||
account_type: body.account_type,
|
||||
normal_balance: body.normal_balance,
|
||||
@@ -103,7 +120,7 @@ export const POST = withRouteContext(
|
||||
is_system_account: false,
|
||||
description: body.description || null,
|
||||
default_vat_code: body.default_vat_code || null,
|
||||
default_vat_rate: body.default_vat_rate ?? null,
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: body.default_vat_treatment ?? null,
|
||||
sru_code: body.sru_code || null,
|
||||
sort_order: parseInt(body.account_number),
|
||||
|
||||
@@ -42,14 +42,15 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
|
||||
|
||||
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
|
||||
|
||||
// Invert the effective mapping. Fixed BAS mappings stay authoritative;
|
||||
// explicit treatments add custom accounts only.
|
||||
// Invert the effective mapping. Explicit account treatments replace the
|
||||
// fixed BAS mapping and can move a standard account to another ruta.
|
||||
const accountsForRuta = Object.entries(ACCOUNT_RUTA)
|
||||
.filter(([, m]) => m.box === rutaKey)
|
||||
.filter(([account, m]) =>
|
||||
m.box === rutaKey && !dynamicVatAccounts.explicitAccounts.has(account)
|
||||
)
|
||||
.map(([acc]) => acc)
|
||||
|
||||
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
|
||||
if (ACCOUNT_RUTA[account]) continue
|
||||
if (mapping.box === rutaKey) accountsForRuta.push(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ const Account = z.object({
|
||||
is_active: z.boolean(),
|
||||
description: z.string().nullable(),
|
||||
default_vat_code: z.string().nullable(),
|
||||
default_vat_rate: z.number().nullable(),
|
||||
default_vat_treatment: z.string().nullable(),
|
||||
sru_code: z.string().nullable(),
|
||||
sort_order: z.number().int(),
|
||||
})
|
||||
@@ -32,7 +34,7 @@ const AccountsResponse = dataEnvelope(z.object({ accounts: z.array(Account) }))
|
||||
const ACCOUNT_COLUMNS =
|
||||
'account_number, account_name, account_class, account_group, account_type, ' +
|
||||
'normal_balance, is_system_account, is_active, description, default_vat_code, ' +
|
||||
'sru_code, sort_order'
|
||||
'default_vat_rate, default_vat_treatment, sru_code, sort_order'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'accounts.list',
|
||||
|
||||
Reference in New Issue
Block a user