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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user