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
@@ -0,0 +1,5 @@
-- The predecessor constraint does not recognize the corrected non-EU service
-- vocabulary. Remove it before the next migration normalizes legacy values;
-- the class-aware constraint is restored immediately afterwards.
ALTER TABLE public.chart_of_accounts
DROP CONSTRAINT IF EXISTS chart_of_accounts_default_vat_treatment_check;
@@ -0,0 +1,12 @@
-- PR #1588 briefly allowed purchase accounts to store revenue-side treatment
-- names. Preserve the supported non-EU service meaning and clear the
-- unsupported import-goods value before the class-aware constraint is added.
UPDATE public.chart_of_accounts
SET default_vat_treatment = 'reverse_charge_non_eu_services'
WHERE account_class BETWEEN 4 AND 6
AND default_vat_treatment = 'export_services';
UPDATE public.chart_of_accounts
SET default_vat_treatment = NULL
WHERE account_class BETWEEN 4 AND 6
AND default_vat_treatment = 'export_goods';
@@ -0,0 +1,24 @@
-- The predecessor API validated enum membership but not account class. After
-- preserving the one lossless legacy rename, clear every remaining invalid
-- combination before the class-aware constraint is installed.
UPDATE public.chart_of_accounts
SET default_vat_treatment = NULL
WHERE default_vat_treatment IS NOT NULL
AND NOT (
(
account_class = 3
AND default_vat_treatment IN (
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'export_goods', 'export_services',
'vmb', 'rental_voluntary'
)
)
OR (
account_class BETWEEN 4 AND 6
AND default_vat_treatment IN (
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services'
)
)
);
@@ -0,0 +1,32 @@
ALTER TABLE public.chart_of_accounts
DROP CONSTRAINT IF EXISTS chart_of_accounts_default_vat_treatment_check;
ALTER TABLE public.chart_of_accounts
ADD CONSTRAINT chart_of_accounts_default_vat_treatment_check
CHECK (
default_vat_treatment IS NULL
OR (
account_class = 3
AND default_vat_treatment IN (
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'export_goods', 'export_services',
'vmb', 'rental_voluntary'
)
)
OR (
account_class BETWEEN 4 AND 6
AND default_vat_treatment IN (
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services'
)
)
) NOT VALID;
ALTER TABLE public.chart_of_accounts
VALIDATE CONSTRAINT chart_of_accounts_default_vat_treatment_check;
COMMENT ON COLUMN public.chart_of_accounts.default_vat_treatment IS
'Per-account momsdeklaration treatment. Explicit values override the built-in BAS account mapping.';
NOTIFY pgrst, 'reload schema';
@@ -1,47 +1,137 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
async function setTreatment(companyId: string, treatment: string | null) {
const NORMALIZE_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150100_normalize_legacy_account_vat_treatments.sql'),
'utf8',
)
const DROP_LEGACY_CONSTRAINT_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150000_drop_legacy_account_vat_treatment_constraint.sql'),
'utf8',
)
const ENFORCE_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150300_enforce_class_aware_account_vat_treatment.sql'),
'utf8',
)
const CLEAR_INCOMPATIBLE_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260815150200_clear_incompatible_account_vat_treatments.sql'),
'utf8',
)
async function setTreatment(companyId: string, accountNumber: string, treatment: string | null) {
return getPool().query(
`UPDATE public.chart_of_accounts
SET default_vat_treatment = $2
WHERE company_id = $1 AND account_number = '3013'`,
[companyId, treatment],
WHERE company_id = $1 AND account_number = $3`,
[companyId, treatment, accountNumber],
)
}
async function insertAccount(companyId: string, userId: string) {
return getPool().query(
async function insertAccount(
companyId: string,
userId: string,
accountNumber: string,
accountClass: number,
) {
await getPool().query(
`INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type,
is_system_account)
VALUES ($1, $2, '3013', 'Test account', 3, '30', 'revenue', 'credit',
'full_bas', false)
RETURNING account_number`,
[userId, companyId],
account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', false)`,
[
userId,
companyId,
accountNumber,
`Test account ${accountNumber}`,
accountClass,
accountNumber.slice(0, 2),
accountClass === 3 ? 'revenue' : 'expense',
accountClass === 3 ? 'credit' : 'debit',
],
)
}
describe('chart_of_accounts.default_vat_treatment', () => {
it('accepts every supported treatment and NULL', async () => {
const { companyId, userId } = await seedCompany()
expect((await insertAccount(companyId, userId)).rowCount).toBe(1)
const treatments = [
await insertAccount(companyId, userId, '3001', 3)
await insertAccount(companyId, userId, '4010', 4)
const revenueTreatments = [
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'export_goods', 'export_services',
'vmb', 'rental_voluntary', null,
]
for (const treatment of treatments) {
await expect(setTreatment(companyId, treatment)).resolves.toMatchObject({ rowCount: 1 })
for (const treatment of revenueTreatments) {
await expect(setTreatment(companyId, '3001', treatment)).resolves.toBeDefined()
}
const purchaseTreatments = [
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services', null,
]
for (const treatment of purchaseTreatments) {
await expect(setTreatment(companyId, '4010', treatment)).resolves.toBeDefined()
}
})
it('rejects unknown treatments', async () => {
const { companyId, userId } = await seedCompany()
expect((await insertAccount(companyId, userId)).rowCount).toBe(1)
await expect(setTreatment(companyId, 'unknown')).rejects.toThrow()
await insertAccount(companyId, userId, '3001', 3)
await expect(setTreatment(companyId, '3001', 'unknown')).rejects.toThrow()
})
it('rejects a treatment that is incompatible with the account class', async () => {
const { companyId, userId } = await seedCompany()
await insertAccount(companyId, userId, '4010', 4)
await expect(setTreatment(companyId, '4010', 'standard_25')).rejects.toThrow()
})
it('normalizes values accepted by the predecessor before enforcing classes', async () => {
const { companyId, userId } = await seedCompany()
await insertAccount(companyId, userId, '1010', 1)
await insertAccount(companyId, userId, '4010', 4)
await insertAccount(companyId, userId, '4011', 4)
await insertAccount(companyId, userId, '4012', 4)
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(DROP_LEGACY_CONSTRAINT_SQL)
await client.query(`
UPDATE public.chart_of_accounts
SET default_vat_treatment = CASE account_number
WHEN '1010' THEN 'standard_25'
WHEN '4010' THEN 'export_services'
WHEN '4011' THEN 'export_goods'
WHEN '4012' THEN 'standard_25'
END
WHERE company_id = $1 AND account_number IN ('1010', '4010', '4011', '4012')
`, [companyId])
await client.query(NORMALIZE_SQL)
await client.query(CLEAR_INCOMPATIBLE_SQL)
await client.query(ENFORCE_SQL)
const result = await client.query<{
account_number: string
default_vat_treatment: string | null
}>(`
SELECT account_number, default_vat_treatment
FROM public.chart_of_accounts
WHERE company_id = $1 AND account_number IN ('1010', '4010', '4011', '4012')
ORDER BY account_number
`, [companyId])
expect(result.rows).toEqual([
{ account_number: '1010', default_vat_treatment: null },
{ account_number: '4010', default_vat_treatment: 'reverse_charge_non_eu_services' },
{ account_number: '4011', default_vat_treatment: null },
{ account_number: '4012', default_vat_treatment: null },
])
} finally {
await client.query('ROLLBACK').catch(() => {})
client.release()
}
})
})