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
+34 -6
View File
@@ -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
+19 -2
View File
@@ -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',