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
@@ -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