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
@@ -189,6 +189,51 @@ describe('commitPendingOperation: update_account', () => {
expect(result.status).toBe('committed')
})
it('derives an omitted booking rate at commit when the stored rate is unset', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { default_vat_rate: null } }) // current account
enqueue({ data: { account_number: '4056', account_name: 'EU-varor', is_active: true } }) // update
enqueue({ data: null }) // finalize update
const op = makePendingOp({
operation_type: 'update_account',
params: {
account_number: '4056',
default_vat_treatment: 'reverse_charge_eu_goods',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(findCalls('chart_of_accounts', 'update')[0]?.[0]).toMatchObject({
default_vat_treatment: 'reverse_charge_eu_goods',
default_vat_rate: 0.25,
})
})
it('preserves a stored booking rate when a staged treatment omits it', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { default_vat_rate: 0.12 } }) // current account
enqueue({ data: { account_number: '4056', account_name: 'EU-varor', is_active: true } }) // update
enqueue({ data: null }) // finalize update
const op = makePendingOp({
operation_type: 'update_account',
params: {
account_number: '4056',
default_vat_treatment: 'reverse_charge_eu_goods',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(findCalls('chart_of_accounts', 'update')[0]?.[0]).toEqual({
default_vat_treatment: 'reverse_charge_eu_goods',
})
})
it('unknown account (PGRST116) auto-rejects with 404', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
+35 -1
View File
@@ -128,6 +128,7 @@ import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pend
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
import { defaultRateForVatTreatment } from '@/lib/vat/account-vat-treatment'
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
@@ -946,6 +947,13 @@ async function commitCreateAccount(
// Same row shape as the dashboard create route
// (app/api/bookkeeping/accounts/route.ts): class/group/sort_order derive
// from the number so the two write paths cannot drift.
const defaultVatRate = validated.default_vat_treatment && validated.default_vat_rate == null
? defaultRateForVatTreatment(
validated.default_vat_treatment,
Number(validated.account_number[0]),
)
: validated.default_vat_rate ?? null
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
@@ -962,7 +970,8 @@ async function commitCreateAccount(
is_system_account: false,
description: validated.description ?? null,
default_vat_code: validated.default_vat_code ?? null,
default_vat_rate: validated.default_vat_rate ?? null,
default_vat_rate: defaultVatRate,
default_vat_treatment: validated.default_vat_treatment ?? null,
sru_code: validated.sru_code ?? null,
sort_order: parseInt(validated.account_number),
})
@@ -1001,6 +1010,31 @@ async function commitUpdateAccount(
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) updateData[key] = value
}
if (validated.default_vat_treatment && validated.default_vat_rate == null) {
const { data: current, error: currentError } = await supabase
.from('chart_of_accounts')
.select('default_vat_rate')
.eq('company_id', companyId)
.eq('account_number', account_number)
.single()
if (currentError) {
if (currentError.code === 'PGRST116') {
return { error: 'Kontot hittades inte', status: 404 }
}
return { error: currentError.message, status: 500 }
}
if (current.default_vat_rate == null) {
updateData.default_vat_rate = defaultRateForVatTreatment(
validated.default_vat_treatment,
Number(account_number.charAt(0)),
)
} else {
delete updateData.default_vat_rate
}
}
if (Object.keys(updateData).length === 0) {
return { error: 'Inget att uppdatera', status: 400 }
}
+35
View File
@@ -1,4 +1,8 @@
import { z } from 'zod'
import {
ACCOUNT_VAT_TREATMENTS,
isVatTreatmentAllowedForAccountClass,
} from '@/lib/vat/account-vat-treatment'
// Commit-boundary re-validation for staged chart-of-accounts operations
// (gnubok_create_account / gnubok_update_account). A staged
@@ -27,6 +31,8 @@ const defaultVatRate = z
.nullable()
.optional()
const defaultVatTreatment = z.enum(ACCOUNT_VAT_TREATMENTS).nullable().optional()
/** Empty string / null → undefined, then bounded string. */
const optString = (max: number) =>
z.preprocess((v) => (v == null || v === '' ? undefined : v), z.string().max(max).optional())
@@ -84,6 +90,7 @@ export const CreateAccountParamsSchema = z
description: optString(2000),
default_vat_code: optString(32),
default_vat_rate: defaultVatRate,
default_vat_treatment: defaultVatTreatment,
sru_code: optString(16),
})
.superRefine((v, ctx) => {
@@ -91,6 +98,19 @@ export const CreateAccountParamsSchema = z
if (conflict) {
ctx.addIssue({ code: 'custom', message: conflict, path: ['account_type'] })
}
if (
v.default_vat_treatment &&
!isVatTreatmentAllowedForAccountClass(
v.default_vat_treatment,
Number(v.account_number[0]),
)
) {
ctx.addIssue({
code: 'custom',
message: 'VAT treatment is not valid for this account class',
path: ['default_vat_treatment'],
})
}
})
export const UpdateAccountParamsSchema = z.object({
@@ -99,8 +119,23 @@ export const UpdateAccountParamsSchema = z.object({
description: clearableString(2000),
default_vat_code: clearableString(32),
default_vat_rate: defaultVatRate,
default_vat_treatment: defaultVatTreatment,
sru_code: clearableString(16),
is_active: z.boolean().optional(),
}).superRefine((v, ctx) => {
if (
v.default_vat_treatment &&
!isVatTreatmentAllowedForAccountClass(
v.default_vat_treatment,
Number(v.account_number[0]),
)
) {
ctx.addIssue({
code: 'custom',
message: 'VAT treatment is not valid for this account class',
path: ['default_vat_treatment'],
})
}
})
export type CreateAccountParams = z.infer<typeof CreateAccountParamsSchema>