feat(vat): add per-account declaration treatments (#1588)

Closes #1457
This commit is contained in:
Mattsson
2026-08-13 17:03:35 +02:00
committed by GitHub
parent 7881f757a9
commit d02fd82191
32 changed files with 1196 additions and 253 deletions
+15 -1
View File
@@ -4,6 +4,7 @@ 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'
// DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active).
@@ -95,7 +96,7 @@ export const PUT = withRouteContext(
// .default() today, so sparsePatchBody is a no-op here: it is the
// structural guarantee that adding one later cannot make a PUT that
// renames an account also rewrite its VAT code or SRU mapping. An
// explicit null (clearing sru_code, default_vat_code, default_vat_rate)
// explicit null (clearing sru_code, VAT defaults, or descriptions)
// still survives.
const validation = await validateBody(request, sparsePatchBody(UpdateAccountSchema), {
log,
@@ -104,6 +105,19 @@ export const PUT = withRouteContext(
if (!validation.success) return validation.response
const body = validation.data
if (
body.default_vat_treatment &&
!isVatTreatmentValidForAccountClass(
body.default_vat_treatment,
Number(number.charAt(0)),
)
) {
return NextResponse.json(
{ error: 'Momshanteringen är inte giltig för kontoklassen' },
{ status: 400 },
)
}
if (Object.keys(body).length === 0) {
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
}
@@ -267,6 +267,28 @@ describe('POST /api/bookkeeping/accounts', () => {
}
expect(insertArg?.default_vat_rate).toBe(0)
})
it('forwards default_vat_treatment into the insert', async () => {
const { supabase, calls } = createCapturingSupabase([{
data: { account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods' },
}])
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_treatment?: string | null
}
expect(insertArg.default_vat_treatment).toBe('reverse_charge_eu_goods')
})
})
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
@@ -376,6 +398,33 @@ 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' },
}])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/3041', {
method: 'PUT',
body: { default_vat_treatment: 'standard_25' },
})
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
}
expect(updateArg.default_vat_treatment).toBe('standard_25')
})
it('rejects a VAT treatment that does not apply to the account class', async () => {
const { supabase, calls } = createCapturingSupabase([])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { default_vat_treatment: 'standard_25' },
})
expect((await PUT(req, numberParams)).status).toBe(400)
expect(calls.some((call) => call.method === 'update')).toBe(false)
})
// The body is spread straight into .update(), so the write set must be
// exactly what the caller named. UpdateAccountSchema carries no .default()
// today; these two lock the property in so adding one cannot turn a rename
+1
View File
@@ -104,6 +104,7 @@ export const POST = withRouteContext(
description: body.description || null,
default_vat_code: body.default_vat_code || null,
default_vat_rate: body.default_vat_rate ?? null,
default_vat_treatment: body.default_vat_treatment ?? null,
sru_code: body.sru_code || null,
sort_order: parseInt(body.account_number),
})
@@ -31,10 +31,19 @@ function buildSupabase(
chartAccounts: Array<{
account_number: string
account_name?: string
account_class?: number
default_vat_rate: number | null
default_vat_treatment?: string | null
}> = []
): SupabaseShape {
const chartResult = { data: chartAccounts, error: null }
const chartResult = {
data: chartAccounts.map((account) => ({
account_class: 3,
default_vat_treatment: null,
...account,
})),
error: null,
}
return {
rpc: vi.fn().mockResolvedValue(linesResult),
from: vi.fn().mockImplementation((table: string) => {
@@ -44,6 +53,8 @@ function buildSupabase(
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
not: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
@@ -228,7 +239,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
})
})
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts', () => {
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: account overrides', () => {
/** The p_accounts array the route handed to get_vat_ruta_source_lines. */
function rpcAccounts(supabase: SupabaseShape): string[] {
return (supabase.rpc.mock.calls[0][1] as { p_accounts: string[] }).p_accounts
@@ -280,7 +291,23 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts
const accounts = rpcAccounts(supabase)
expect(accounts).toContain('2611')
expect(accounts).not.toContain('3013')
expect(supabase.from).not.toHaveBeenCalledWith('chart_of_accounts')
// Every ruta resolves explicit account overrides before the static BAS
// fallback, but a ruta 05-only custom account still stays out of ruta 10.
expect(supabase.from).toHaveBeenCalledWith('chart_of_accounts')
})
it('drills into a custom EU purchase account in ruta 20', async () => {
const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [{
account_number: '4056',
account_name: 'Inköp varor 25% EU',
account_class: 4,
default_vat_rate: 0.25,
default_vat_treatment: 'reverse_charge_eu_goods',
}])
authOk(supabase)
expect((await get('20')).status).toBe(200)
expect(rpcAccounts(supabase)).toContain('4056')
})
})
@@ -4,7 +4,7 @@ import {
ACCOUNT_RUTA,
resolvePeriodDates,
} from '@/lib/reports/vat-declaration'
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
import type { VatDeclarationRutor, VatPeriodType } from '@/types'
@@ -40,17 +40,17 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
rutaParam.startsWith('ruta') ? rutaParam : `ruta${rutaParam}`
) as keyof VatDeclarationRutor
// Invert ACCOUNT_RUTA: which BAS accounts feed this ruta?
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
// Invert the effective mapping. Fixed BAS mappings stay authoritative;
// explicit treatments add custom accounts only.
const accountsForRuta = Object.entries(ACCOUNT_RUTA)
.filter(([, m]) => m.box === rutaKey)
.map(([acc]) => acc)
// Ruta 05 also collects the company's own momspliktiga intäktskonton, which
// ACCOUNT_RUTA cannot know about (#1261). Without them the drill-down would
// list a smaller sum than the figure it drills into.
if (rutaKey === 'ruta05') {
const { accounts } = await fetchDynamicRuta05Accounts(supabase, companyId)
accountsForRuta.push(...accounts)
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
if (ACCOUNT_RUTA[account]) continue
if (mapping.box === rutaKey) accountsForRuta.push(account)
}
if (accountsForRuta.length === 0) {