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
parent edfdbe2d2a
commit 86f0b70fdd
43 changed files with 1017 additions and 359 deletions
+2
View File
@@ -946,6 +946,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end.
[2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment.
[2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales.
[2026-08-13] Correct the shipped VAT-treatment constraint in a new migration: the predecessor PR reached main while the replacement review was active, so the immutable original migration stays byte-identical and the class-aware vocabulary is enforced additively with NOT VALID plus validation.
[2026-08-13] Issue #1457 supersedes the stale canonical-account exception above: an explicit per-account VAT treatment always overrides BAS fallback, while a reverse-charge treatment derives the editable 25 percent standard rate only when no booking default exists; class 5-6 SIE accounts require review when a treatment is actually suggested, not for every ordinary expense account.
[2026-08-13] AGI needs two SKV scopes, not one: added `agdredovisningperiod` (hanteraredovisningsperiod: kvittenser/las) alongside `agd` (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with `agd` alone fails only at the last step.
[2026-08-13] Reversed the #1250 rule that the APIGW "required scopes are not authorized" body must name only the subscription: production proved the same body also fires when the TOKEN lacks the scope (AGI kvittens needed agdredovisningperiod), so naming one cause and hiding the other presents a coin flip as a diagnosis. The message now names both knobs plus the refused API path, and system mode points at SKATTEVERKET_SYSTEM_SCOPES again. Classification is unchanged (ACCESS_DENIED, still not a reconsent code): a reconnect only helps once the scope exists, so it can never be automatic.
[2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue.
+40 -29
View File
@@ -65,6 +65,7 @@ import type {
} from '@/lib/import/types'
import {
applyVatTreatmentReview,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '@/lib/import/account-vat-treatment'
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
@@ -604,15 +605,14 @@ function SIEImportWizard() {
setIssues(data.parsed.issues)
setSieAccounts(data.parsed.accounts)
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (accountsRes.ok) {
const accountsData = await accountsRes.json()
const accounts = accountsData.data || []
setBasAccounts(accounts)
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
} else {
setMappings(enrichAccountMappingsWithVat(data.mappings, []))
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
if (!accountsRes.ok) {
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
}
const accountsData = await accountsRes.json()
const accounts = accountsData.data || []
setBasAccounts(accounts)
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
setStep('preview')
@@ -704,11 +704,19 @@ function SIEImportWizard() {
}, [file, handleFileSelect, toast])
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName))
setMappings((prev) => enrichChangedAccountMappingWithVat(
applyMappingOverride(prev, sourceAccount, targetAccount, targetName),
sourceAccount,
basAccounts,
))
setPreview((prev) => {
if (!prev) return prev
const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName)
const updatedMappings = enrichChangedAccountMappingWithVat(
applyMappingOverride(mappings, sourceAccount, targetAccount, targetName),
sourceAccount,
basAccounts,
)
const mapped = updatedMappings.filter((m) => m.targetAccount).length
const unmapped = updatedMappings.length - mapped
const lowConfidence = updatedMappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
@@ -723,7 +731,7 @@ function SIEImportWizard() {
},
}
})
}, [mappings])
}, [basAccounts, mappings])
const handleVatTreatmentChange = useCallback((
sourceAccount: string,
@@ -771,13 +779,25 @@ function SIEImportWizard() {
toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` })
// Optimistically update mappings: mark created accounts as self-mapped
const createdSet = new Set(missingAccounts.map(a => a.number))
setMappings(prev => enrichAccountMappingsWithVat(prev.map(m =>
!m.targetAccount && createdSet.has(m.sourceAccount)
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
: m
), basAccounts))
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
if (!accountsRes.ok) {
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
}
const accountsData = await accountsRes.json()
const accounts = accountsData.data || []
setBasAccounts(accounts)
setMappings(prev => {
let updated = prev.map(m =>
!m.targetAccount && createdSet.has(m.sourceAccount)
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
: m
)
for (const sourceAccount of createdSet) {
updated = enrichChangedAccountMappingWithVat(updated, sourceAccount, accounts)
}
return updated
})
setPreview(prev => {
if (!prev) return prev
const newMapped = prev.mappingStatus.mapped + createdSet.size
@@ -790,21 +810,12 @@ function SIEImportWizard() {
},
}
})
// Also refresh BAS accounts list
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (accountsRes.ok) {
const accountsData = await accountsRes.json()
const accounts = accountsData.data || []
setBasAccounts(accounts)
setMappings(prev => enrichAccountMappingsWithVat(prev, accounts))
}
} catch (err) {
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' })
} finally {
setIsCreatingAccounts(false)
}
}, [basAccounts, missingAccounts, toast])
}, [missingAccounts, toast])
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
if (!file) { setError('No file selected'); return }
@@ -941,8 +952,8 @@ function SIEImportWizard() {
preview={preview} theaterModel={theaterModel}
unresolvedVatAccountCount={mappings.filter((mapping) =>
mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) &&
!mapping.vatTreatmentReviewed
['3', '4'].includes(mapping.sourceAccount.charAt(0)) &&
!mapping.defaultVatTreatment
).length} />
)}
</div>
+34 -6
View File
@@ -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
+19 -2
View File
@@ -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',
@@ -22,7 +22,7 @@ export function AccountVatTreatmentSelect({
const t = useTranslations('chart_of_accounts')
const isRelevant = accountClass === 3 ||
(accountClass !== null && accountClass >= 4 && accountClass <= 6)
const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass)
const treatments = vatTreatmentsForAccountClass(accountClass)
return (
<div className="space-y-2">
+15 -2
View File
@@ -22,6 +22,7 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
isVatTreatmentAllowedForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
@@ -72,6 +73,8 @@ export function AddAccountDialog({
const num = (initialAccountNumber ?? '').replace(/\D/g, '').slice(0, 4)
setAccountNumber(num)
setAccountName(initialAccountName ?? '')
setDefaultVatRate('none')
setDefaultVatTreatment('none')
setError('')
setInactiveConflict(false)
if (num.length === 4) {
@@ -209,6 +212,16 @@ export function AddAccountDialog({
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
setAccountNumber(v)
const nextClass = v.length > 0 ? Number(v[0]) : null
if (
defaultVatTreatment !== 'none' &&
(nextClass === null || !isVatTreatmentAllowedForAccountClass(
defaultVatTreatment,
nextClass,
))
) {
setDefaultVatTreatment('none')
}
// The conflict is about a specific number; editing it makes
// the reactivate offer stale.
setInactiveConflict(false)
@@ -274,9 +287,9 @@ export function AddAccountDialog({
accountClass={derived ? Number(accountNumber.charAt(0)) : null}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
if (treatment !== 'none' && defaultVatRate === 'none') {
const rate = defaultRateForVatTreatment(treatment, Number(accountNumber.charAt(0)))
setDefaultVatRate(rate === null ? 'none' : String(rate))
if (rate !== null) setDefaultVatRate(String(rate))
}
}}
/>
+2 -2
View File
@@ -327,9 +327,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
accountClass={account.account_class}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
if (treatment !== 'none' && defaultVatRate === 'none') {
const rate = defaultRateForVatTreatment(treatment, account.account_class)
setDefaultVatRate(rate === null ? 'none' : String(rate))
if (rate !== null) setDefaultVatRate(String(rate))
}
}}
/>
@@ -239,8 +239,14 @@ interface MigrationResults {
import AccountMappingStep from '@/components/import/AccountMappingStep'
import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater'
import TheaterCanvas from '@/components/import/TheaterCanvas'
import {
applyVatTreatmentReview,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '@/lib/import/account-vat-treatment'
import type { TheaterModel } from '@/lib/import/theater-model'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import type { BASAccount } from '@/types'
// ── Types ────────────────────────────────────────────────────────
@@ -962,6 +968,7 @@ function MappingStep({
error,
errorDetails,
onMappingChange,
onVatTreatmentChange,
onContinue,
onBack,
}: {
@@ -970,6 +977,11 @@ function MappingStep({
error: string | null
errorDetails: string[] | null
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => void
onContinue: () => void
onBack: () => void
}) {
@@ -1012,6 +1024,7 @@ function MappingStep({
mappings={sieData.mappings}
basAccounts={sieData.basAccounts}
onMappingChange={onMappingChange}
onVatTreatmentChange={onVatTreatmentChange}
onContinue={onContinue}
onBack={onBack}
/>
@@ -1972,6 +1985,7 @@ export default function ArcimMigrationWorkspace({
// SIE data state (held between mapping and execution steps)
const [sieData, setSieData] = useState<SIEData | null>(null)
const companyAccountsForVatRef = useRef<BASAccount[]>([])
// Options state
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
@@ -2539,16 +2553,29 @@ export default function ArcimMigrationWorkspace({
throw apiError(data, `HTTP ${res.status}`)
}
const data = await res.json()
setSieData(data)
const data = await res.json() as SIEData
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
const accountsBody = await accountsRes.json().catch(() => ({})) as {
data?: BASAccount[]
error?: unknown
}
if (!accountsRes.ok) {
throw apiError(accountsBody, `HTTP ${accountsRes.status}`)
}
companyAccountsForVatRef.current = accountsBody.data ?? []
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, accountsBody.data ?? [])
setSieData({ ...data, mappings: enrichedMappings })
// If all SIE files are already imported, disable SIE import by default
if (data.allImported) {
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
// Auto-skip mapping step if all accounts are mapped or all files already imported
if (data.mappingStats.unmapped === 0 || data.allImported) {
const needsVatReview = enrichedMappings.some(mapping =>
mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed
)
// Auto-skip only when there is neither account mapping nor VAT review work.
if ((data.mappingStats.unmapped === 0 && !needsVatReview) || data.allImported) {
setStep('options')
}
} catch (err) {
@@ -2571,10 +2598,14 @@ export default function ArcimMigrationWorkspace({
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
if (!sieData) return
const updatedMappings = sieData.mappings.map(m =>
m.sourceAccount === sourceAccount
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
: m
const updatedMappings = enrichChangedAccountMappingWithVat(
sieData.mappings.map(m =>
m.sourceAccount === sourceAccount
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
: m
),
sourceAccount,
companyAccountsForVatRef.current,
)
setSieData(prev => prev ? {
...prev,
@@ -2587,6 +2618,21 @@ export default function ArcimMigrationWorkspace({
} : null)
}, [sieData])
const handleVatTreatmentChange = useCallback((
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => {
setSieData(prev => prev ? {
...prev,
mappings: applyVatTreatmentReview(prev.mappings, sourceAccount, treatment, rate),
} : null)
}, [])
const handleMappingContinue = useCallback(() => {
setStep('options')
}, [])
const handleStartMigration = useCallback(async () => {
if (!consentId) return
@@ -2832,7 +2878,8 @@ export default function ArcimMigrationWorkspace({
error={error}
errorDetails={errorDetails}
onMappingChange={handleMappingChange}
onContinue={() => setStep('options')}
onVatTreatmentChange={handleVatTreatmentChange}
onContinue={handleMappingContinue}
onBack={() => setStep('preview')}
/>
)}
+49 -44
View File
@@ -42,7 +42,7 @@ interface AccountMappingStepProps {
mappings: AccountMapping[]
basAccounts: BASAccount[]
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange?: (
onVatTreatmentChange: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
@@ -238,8 +238,8 @@ export default function AccountMappingStep({
<TableHead className="w-36">Källkonto</TableHead>
<TableHead>Källnamn</TableHead>
<TableHead className="w-12"></TableHead>
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
<TableHead className="w-64">Målkonto</TableHead>
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
<TableHead className="w-24">Konfidens</TableHead>
</TableRow>
</TableHeader>
@@ -255,8 +255,43 @@ export default function AccountMappingStep({
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{onVatTreatmentChange &&
mapping.sourceAccount === mapping.targetAccount &&
<Select
value={mapping.targetAccount || 'none'}
onValueChange={(value) => {
const account = basAccounts.find((a) => a.account_number === value)
onMappingChange(
mapping.sourceAccount,
value === 'none' ? '' : value,
account?.account_name || ''
)
}}
>
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
<SelectValue placeholder="Välj konto..." />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="none">-- Välj konto --</SelectItem>
{Object.entries(accountsByClass).map(([className, accounts]) => (
<div key={className}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
{className}
</div>
{accounts.map((account) => (
<SelectItem
key={account.account_number}
value={account.account_number}
>
<span className="font-mono mr-2">{account.account_number}</span>
{account.account_name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
<div className="flex min-w-72 gap-2">
<Select
@@ -267,8 +302,10 @@ export default function AccountMappingStep({
: value as AccountVatTreatment
const accountClass = Number(mapping.sourceAccount.charAt(0))
const rate = treatment
? defaultRateForVatTreatment(treatment, accountClass)
: null
? mapping.defaultVatRate == null || mapping.vatTreatmentSuggested
? defaultRateForVatTreatment(treatment, accountClass)
: mapping.defaultVatRate
: mapping.defaultVatRate ?? null
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
}}
>
@@ -278,7 +315,7 @@ export default function AccountMappingStep({
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{vatTreatmentsForAccountClass(
Number(mapping.sourceAccount.charAt(0)),
Number(mapping.sourceAccount.charAt(0))
).map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
@@ -326,42 +363,6 @@ export default function AccountMappingStep({
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Select
value={mapping.targetAccount || 'none'}
onValueChange={(value) => {
const account = basAccounts.find((a) => a.account_number === value)
onMappingChange(
mapping.sourceAccount,
value === 'none' ? '' : value,
account?.account_name || ''
)
}}
>
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
<SelectValue placeholder="Välj konto..." />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="none">-- Välj konto --</SelectItem>
{Object.entries(accountsByClass).map(([className, accounts]) => (
<div key={className}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
{className}
</div>
{accounts.map((account) => (
<SelectItem
key={account.account_number}
value={account.account_number}
>
<span className="font-mono mr-2">{account.account_number}</span>
{account.account_name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{mapping.targetAccount && (
<ConfidenceBadge
@@ -422,7 +423,11 @@ export default function AccountMappingStep({
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
{canContinue ? 'Fortsätt till granskning' : `${stats.unmapped} konton saknar mappning`}
{canContinue
? 'Fortsätt till granskning'
: stats.unmapped > 0
? `${stats.unmapped} konton saknar mappning`
: `${stats.vatReview} momskoder återstår att bekräfta`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
@@ -392,4 +392,38 @@ describe('gnubok_update_account', () => {
expect(result.preview.current.account_name).toBe('Förbrukningsinventarier')
expect(result.preview.changes).toEqual({ account_name: 'Verktyg', is_active: false })
})
it('preserves an existing booking rate when only treatment changes', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
account_number: '4056', account_name: 'EU-varor', default_vat_rate: 0.12,
default_vat_treatment: null, is_active: true,
},
})
const result = (await updateAccount.execute(
{ account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods', dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: { changes: Record<string, unknown> } }
expect(result.preview.changes).toEqual({
default_vat_treatment: 'reverse_charge_eu_goods',
})
})
it('can clear a treatment to restore BAS fallback', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
account_number: '3041', account_name: 'Försäljning', default_vat_rate: 0.25,
default_vat_treatment: 'standard_25', is_active: true,
},
})
const result = (await updateAccount.execute(
{ account_number: '3041', default_vat_treatment: null, dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { preview: { changes: Record<string, unknown> } }
expect(result.preview.changes).toEqual({ default_vat_treatment: null })
})
})
@@ -168,9 +168,12 @@ describe('tools/list payload size guard', () => {
// Both property descriptions trimmed to one sentence first; headroom
// before the change was under 20 tokens, so even the trimmed wire
// contract crossed.
// * 59.5K to 59.7K with account VAT treatments: create_account and
// update_account both expose the 12-value treatment vocabulary. The
// descriptions are minimal; the enum values are the wire contract.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(59_500)
expect(approxTokens).toBeLessThan(59_700)
})
})
@@ -41,7 +41,9 @@ interface MockLine {
interface MockChartAccount {
account_number: string
account_name: string
account_class: number
default_vat_rate: number | null
default_vat_treatment: string | null
}
/**
@@ -127,7 +129,9 @@ describe('gnubok_vat_close_check: declaration completeness', () => {
[{
account_number: '3011',
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
account_class: 3,
default_vat_rate: null,
default_vat_treatment: null,
}],
),
)
@@ -139,6 +143,54 @@ describe('gnubok_vat_close_check: declaration completeness', () => {
expect(result.ready_to_close).toBe(true)
})
it('honors an explicit override of a standard BAS revenue account', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase(
[
{ entry: 'e1', account_number: '3011', credit_amount: 1000 },
{ entry: 'e1', account_number: '1510', debit_amount: 1000 },
],
[{
account_number: '3011',
account_name: 'Momsfri försäljning',
account_class: 3,
default_vat_rate: 0,
default_vat_treatment: 'exempt',
}],
),
)
expect(result.rutor.ruta05).toBe(0)
})
it('accepts a custom EU goods basis account in the MCP close check', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase(
[
{ entry: 'e1', account_number: '4056', debit_amount: 1000 },
{ entry: 'e1', account_number: '2440', credit_amount: 1000 },
{ entry: 'e1', account_number: '2614', credit_amount: 250 },
{ entry: 'e1', account_number: '2645', debit_amount: 250 },
],
[{
account_number: '4056',
account_name: 'Inköp varor EU 25 %',
account_class: 4,
default_vat_rate: 0.25,
default_vat_treatment: 'reverse_charge_eu_goods',
}],
),
)
expect(result.declaration_checks.map((finding) => finding.code))
.not.toContain('RC_BASIS_MISSING')
expect(result.ready_to_close).toBe(true)
})
it('refuses the #1164 declaration: fiktiv moms on 2614/2645 with no basbelopp on 44xx/45xx', async () => {
// Both VAT legs of a reverse-charge purchase booked, but the cost went
// straight to 6540 instead of the 4535 basis account, so rutor 20-24 stay
@@ -17,6 +17,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { VatDeclarationRutor } from '@/types'
const { mockFindRcBasisGaps } = vi.hoisted(() => ({
mockFindRcBasisGaps: vi.fn(),
}))
const mockSkvRequest = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
@@ -47,10 +50,8 @@ vi.mock('@/lib/reports/vat-declaration', async (importOriginal) => {
}
})
// The per-verifikat FK004 scan has its own coverage in lib/reports/__tests__;
// here it must simply not add findings of its own.
vi.mock('@/lib/reports/rc-basis-gaps', () => ({
findRcBasisGaps: vi.fn(async () => []),
findRcBasisGaps: (...args: unknown[]) => mockFindRcBasisGaps(...args),
}))
import { tools } from '../server'
@@ -87,10 +88,12 @@ function makeRutor(partial: Partial<VatDeclarationRutor> = {}): VatDeclarationRu
function setDeclaration(
rutor: VatDeclarationRutor,
rcInput?: Record<string, { debit: number; credit: number }>,
rcBasisByRate?: { r25: number; r12: number; r6: number },
) {
mockCalculateVatDeclaration.mockResolvedValue({
rutor,
...(rcInput ? { rcInputAccountTotals: rcInput } : {}),
...(rcBasisByRate ? { rcBasisByRate } : {}),
})
}
@@ -116,6 +119,7 @@ function skvOk(body: unknown = { kontrollResultat: { status: 'OK', resultat: []
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
mockFindRcBasisGaps.mockResolvedValue([])
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
})
@@ -215,6 +219,22 @@ describe('gnubok_vat_declaration_validate', () => {
expect(result.completeness_ok).toBe(true)
})
it('uses declaration rate evidence when a correction gap is covered in aggregate', async () => {
mockFindRcBasisGaps.mockResolvedValue([{}])
setDeclaration(
makeRutor({ ruta20: 5000, ruta30: 1250, ruta48: 1250 }),
rcInput({ '2645': 1250 }),
{ r25: 5000, r12: 0, r6: 0 },
)
skvOk()
const result = await run()
expect(result.completeness_checks.find((c) => c.code === 'RC_BASIS_MISSING')?.status)
.toBe('WARNING')
expect(result.completeness_ok).toBe(true)
})
it('arithmetic_ok is false when Skatteverket returns an ERROR, independently of completeness', async () => {
setDeclaration(CLEAN)
skvOk({
@@ -9,6 +9,8 @@ interface AccountSummary {
normal_balance: string
is_active: boolean
default_vat_code: string | null
default_vat_rate: number | null
default_vat_treatment: string | null
}
export const chartOfAccountsResource: McpResource = {
@@ -25,7 +27,7 @@ export const chartOfAccountsResource: McpResource = {
accounts = await fetchAllRows<AccountSummary>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code')
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code, default_vat_rate, default_vat_treatment')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
+83 -57
View File
@@ -48,7 +48,7 @@ import {
rcInputTotalsFromDeclaration,
calculateVatDeclaration,
} from '@/lib/reports/vat-declaration'
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts'
// The momsdeklaration completeness checks live in core (lib/reports) and are
// shared with the web UI's "Kontroll av underlaget" gate. The MCP surface
// imports them instead of mirroring them: a hand-rolled copy here is exactly
@@ -88,6 +88,12 @@ import {
} from './tool-namespace'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import {
ACCOUNT_VAT_TREATMENTS,
defaultRateForVatTreatment,
isAccountVatTreatment,
isVatTreatmentAllowedForAccountClass,
} from '@/lib/vat/account-vat-treatment'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/account'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
@@ -1359,33 +1365,15 @@ const SKV_AGI_STATUS_OUTPUT_SCHEMA = {
// (the original sale's VAT silently disappears) and over-credit Period N+M
// (a reversal with no original), incorrect per ML 2023:200.
/** Common BAS taxable-revenue accounts that contribute to ruta 05.
*
* Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri,
* exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the
* rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the
* buyer's VAT number is invalid).
*
* This hand-maintained widening predates #1261 and is kept so no company
* loses a figure it already saw. It is no longer the only path: a company's
* own class 3 konto marked with a moms-sats is resolved at runtime by
* fetchDynamicRuta05Accounts and unioned in below, which is what actually
* covers non-standard charts (Accounted's BAS chart ships no varugrupp
* accounts at all). */
const RUTA_05_ACCOUNTS = [
// The 30xx gruppkonto. ACCOUNT_RUTA maps it to ruta05, so leaving it out here
// made a balance on 3000 appear in the filed projection but not in
// report.rutor.ruta05.
// Keep the MCP report's historical ruta 05 widening for accounts that do not
// have an explicit treatment. The effective resolver adds custom ruta 05
// accounts and removes any of these when the company overrides their treatment.
const RUTA_05_COMPATIBILITY_ACCOUNTS = [
'3000',
// Domestic sales by VAT rate (canonical BAS)
'3001', '3002', '3003', '3005', '3006', '3007', '3008',
// Taxable EU goods (momspliktig, buyer's VAT number invalid or buyer is private)
'3106',
// Domestic services (alternative numbering some companies use)
'3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048',
// Domestic goods (alternative numbering)
'3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058',
// Other domestic taxable
'3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078',
] as const
@@ -1408,6 +1396,7 @@ export interface VatReportResult {
export interface VatReportWithRutor {
report: VatReportResult
dynamicVatAccounts: Awaited<ReturnType<typeof fetchDynamicVatAccounts>>
/**
* The FULL SKV 4700 projection of the same ledger aggregate, via core's
* `rutorFromTotals`. `report.rutor` is the trimmed agent-facing view: it has
@@ -1546,43 +1535,36 @@ export async function computeVatReportWithRutor(
accountTotals.set(acc, existing)
}
function creditBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0
}
function debitBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0
}
// The company's own momspliktiga intäktskonton join the hand-maintained list.
// Deduped: an account can appear in both (e.g. 3041 with a moms-sats set),
// and counting it twice would inflate ruta 05.
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
const ruta05Accounts = [...new Set([...RUTA_05_ACCOUNTS, ...dynamicRuta05.accounts])]
const ruta05 = ruta05Accounts.reduce((sum, acc) => sum + creditBalance(acc), 0)
const ruta10 = creditBalance('2611')
const ruta11 = creditBalance('2621')
const ruta12 = creditBalance('2631')
const ruta30 = creditBalance('2614')
const ruta31 = creditBalance('2624')
const ruta32 = creditBalance('2634')
const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU)
const ruta39 = creditBalance('3308')
const ruta40 = creditBalance('3305')
function creditBalance(acc: string): number {
return -debitBalance(acc)
}
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
const declarationRutor = rutorFromTotals(accountTotals, dynamicVatAccounts)
const {
ruta10, ruta11, ruta12, ruta30, ruta31, ruta32,
ruta35, ruta39, ruta40, ruta48, ruta49, ruta60, ruta61, ruta62,
} = declarationRutor
const reportRuta05Accounts = new Set<string>(
RUTA_05_COMPATIBILITY_ACCOUNTS.filter(
(account) => !dynamicVatAccounts.explicitAccounts.has(account),
),
)
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
if (mapping.box === 'ruta05') reportRuta05Accounts.add(account)
}
const ruta05 = [...reportRuta05Accounts]
.reduce((sum, account) => sum + creditBalance(account), 0)
// Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the
// importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the
// matching deductible input to 2645 (rolls into ruta 48 below).
const ruta60 = creditBalance('2615')
const ruta61 = creditBalance('2625')
const ruta62 = creditBalance('2635')
const calculatedInput2645 = debitBalance('2645')
const calculatedInput2647 = debitBalance('2647')
const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647
const ruta49 = Math.round(
(ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100
) / 100
const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December']
@@ -1642,7 +1624,8 @@ export async function computeVatReportWithRutor(
// (incl. rutor 20-24 and 50) instead of the trimmed report view.
return {
report,
declarationRutor: rutorFromTotals(accountTotals, dynamicRuta05.accounts),
declarationRutor,
dynamicVatAccounts,
accountTotals,
}
}
@@ -1676,6 +1659,8 @@ async function runVatCompletenessChecks(
year: number,
period: number,
accountTotals?: VatCheckAccountTotals,
dynamicVatAccounts?: Awaited<ReturnType<typeof fetchDynamicVatAccounts>>,
rcBasisByRate?: { r25: number; r12: number; r6: number },
): Promise<VatDeclarationCheck[]> {
let scan: RcBasisGapScan
try {
@@ -1688,7 +1673,10 @@ async function runVatCompletenessChecks(
// caller supplied the account totals; without them the per-voucher gaps
// keep their blocking ERROR tier rather than guessing.
const evidence = accountTotals
? { rutor, rcBasisByRate: rcBasisTotalsByRate(accountTotals) }
? {
rutor,
rcBasisByRate: rcBasisByRate ?? rcBasisTotalsByRate(accountTotals, dynamicVatAccounts),
}
: undefined
return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan, evidence)
}
@@ -2073,7 +2061,7 @@ export async function computeVatCloseCheck(
// step 4b: they need rutor 20-24 and 50, which the report view omits, plus
// the per-account totals so the RC input comparison reads 2645/2647
// instead of the ruta 48 aggregate.
const { report: vatReport, declarationRutor, accountTotals } =
const { report: vatReport, declarationRutor, dynamicVatAccounts, accountTotals } =
await computeVatReportWithRutor(args, companyId, supabase)
const { start, end, type: periodType, year, period } = vatReport.period
@@ -2184,6 +2172,7 @@ export async function computeVatCloseCheck(
Number(year),
Number(period),
accountTotals,
dynamicVatAccounts,
)
// Zero deductible input VAT against self-assessed utgående moms is
@@ -6409,6 +6398,11 @@ export const tools: McpTool[] = [
description: { type: 'string' },
default_vat_code: { type: 'string' },
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
default_vat_treatment: {
type: 'string',
enum: [...ACCOUNT_VAT_TREATMENTS],
description: 'VAT return treatment.',
},
sru_code: { type: 'string', description: 'Prefilled for BAS numbers.' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
@@ -6470,6 +6464,17 @@ export const tools: McpTool[] = [
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
}
const vatTreatment = args.default_vat_treatment
if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) {
throw new Error('default_vat_treatment is not supported')
}
const accountClass = Number(accountNumber[0])
if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) {
throw new Error('default_vat_treatment is not valid for this account class')
}
const effectiveVatRate = vatTreatment && vatRate === undefined
? defaultRateForVatTreatment(vatTreatment, accountClass)
: vatRate
const params: Record<string, unknown> = {
account_number: accountNumber,
@@ -6479,7 +6484,8 @@ export const tools: McpTool[] = [
plan_type: ref ? 'full_bas' : 'k1',
description: String(args.description ?? '').trim() || ref?.description || undefined,
default_vat_code: String(args.default_vat_code ?? '').trim() || undefined,
default_vat_rate: vatRate,
default_vat_rate: effectiveVatRate,
default_vat_treatment: vatTreatment,
sru_code: String(args.sru_code ?? '').trim() || ref?.sru_code || undefined,
}
@@ -6514,6 +6520,11 @@ export const tools: McpTool[] = [
description: { type: 'string' },
default_vat_code: { type: 'string' },
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Default VAT rate as a fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
default_vat_treatment: {
type: ['string', 'null'],
enum: [...ACCOUNT_VAT_TREATMENTS, null],
description: 'VAT return treatment.',
},
sru_code: { type: 'string' },
is_active: { type: 'boolean', description: 'false deactivates (hides from pickers, keeps history); true (re)activates.' },
dry_run: { type: 'boolean' },
@@ -6535,7 +6546,7 @@ export const tools: McpTool[] = [
const { data: current, error: fetchErr } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, description, default_vat_code, default_vat_rate, sru_code, is_active')
.select('account_number, account_name, description, default_vat_code, default_vat_rate, default_vat_treatment, sru_code, is_active')
.eq('company_id', companyId)
.eq('account_number', accountNumber)
.maybeSingle()
@@ -6548,17 +6559,30 @@ export const tools: McpTool[] = [
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
}
const vatTreatment = args.default_vat_treatment
if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) {
throw new Error('default_vat_treatment is not supported')
}
const accountClass = Number(accountNumber[0])
if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) {
throw new Error('default_vat_treatment is not valid for this account class')
}
const params: Record<string, unknown> = { account_number: accountNumber }
const changes: Record<string, unknown> = {}
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'sru_code', 'is_active']) {
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'default_vat_treatment', 'sru_code', 'is_active']) {
if (args[key] !== undefined) {
params[key] = args[key]
changes[key] = args[key]
}
}
if (vatTreatment && vatRate === undefined && current.default_vat_rate == null) {
const derivedRate = defaultRateForVatTreatment(vatTreatment, accountClass)
params.default_vat_rate = derivedRate
changes.default_vat_rate = derivedRate
}
if (Object.keys(changes).length === 0) {
throw new Error('Nothing to update: pass at least one of account_name, description, default_vat_code, default_vat_rate, sru_code, is_active.')
throw new Error('Nothing to update: pass at least one account field.')
}
return stagePendingOperation(supabase, companyId, userId, 'update_account',
@@ -11221,6 +11245,8 @@ export const tools: McpTool[] = [
const completenessChecks = await runVatCompletenessChecks(
supabase, companyId, declaration.rutor, periodType, year, period,
rcInputTotalsFromDeclaration(declaration),
undefined,
declaration.rcBasisByRate,
)
const completenessOk = !isFilingBlocked(completenessChecks)
+3 -17
View File
@@ -1996,23 +1996,6 @@ describe('CreateAccountSchema', () => {
})
expect(result.success).toBe(false)
})
it('rejects a VAT treatment that does not apply to the account class', () => {
expect(CreateAccountSchema.safeParse({
account_number: '5010',
account_name: 'Consulting costs',
account_type: 'expense',
normal_balance: 'debit',
default_vat_treatment: 'standard_25',
}).success).toBe(false)
expect(CreateAccountSchema.safeParse({
account_number: '5010',
account_name: 'EU services',
account_type: 'expense',
normal_balance: 'debit',
default_vat_treatment: 'reverse_charge_eu_services',
}).success).toBe(true)
})
})
// ============================================================
@@ -2327,6 +2310,9 @@ describe('UpdateAccountSchema', () => {
expect(UpdateAccountSchema.safeParse({
default_vat_treatment: 'reverse_charge_eu_goods',
}).success).toBe(true)
expect(UpdateAccountSchema.safeParse({
default_vat_treatment: 'reverse_charge_non_eu_services',
}).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: null }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: 'eu_purchase' }).success).toBe(false)
})
+2 -17
View File
@@ -16,7 +16,6 @@ import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment'
// ============================================================
// Shared primitives
@@ -2124,8 +2123,8 @@ const defaultVatRate = z
export const AccountVatTreatmentSchema = z.enum([
'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',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
])
const defaultVatTreatment = AccountVatTreatmentSchema.nullable().optional()
@@ -2141,20 +2140,6 @@ export const CreateAccountSchema = z.object({
default_vat_rate: defaultVatRate,
default_vat_treatment: defaultVatTreatment,
sru_code: z.string().nullable().optional(),
}).superRefine((value, ctx) => {
if (
value.default_vat_treatment &&
!isVatTreatmentValidForAccountClass(
value.default_vat_treatment,
Number(value.account_number.charAt(0)),
)
) {
ctx.addIssue({
code: 'custom',
path: ['default_vat_treatment'],
message: 'VAT treatment is not valid for the account class',
})
}
})
export const UpdateAccountSchema = z.object({
+16
View File
@@ -121,6 +121,22 @@ describe('syncMappedAccounts: create pass', () => {
})
})
it('derives the booking rate for a confirmed treatment when the rate is unset', async () => {
const { supabase, inserts } = buildCapturingSupabase()
const result = await run(supabase, [
mapping({
sourceAccount: '4056',
targetAccount: '4056',
sourceName: 'Inköp varor EU',
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: null,
vatTreatmentReviewed: true,
}),
])
expect(result.error).toBeNull()
expect(inserts[0].default_vat_rate).toBe(0.25)
})
it('creates a missing BAS account with the BAS default name when the file has no custom name', async () => {
const { supabase, inserts } = buildCapturingSupabase()
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
applyVatTreatmentReview,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '../account-vat-treatment'
import type { AccountMapping } from '../types'
@@ -32,28 +33,6 @@ describe('enrichAccountMappingsWithVat', () => {
})
})
it('requires review for suggested class 5 and 6 purchase treatments', () => {
const results = enrichAccountMappingsWithVat(
[
mapping('5010', 'Inköp tjänst EU'),
mapping('6010', 'Inköp tjänst utanför EU'),
],
[],
)
expect(results).toEqual([
expect.objectContaining({
defaultVatTreatment: 'reverse_charge_eu_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
expect.objectContaining({
defaultVatTreatment: 'export_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
])
})
it('keeps an existing account treatment without asking again', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('3041', 'Försäljning tjänst 25% sv')],
@@ -70,25 +49,99 @@ describe('enrichAccountMappingsWithVat', () => {
requiresVatTreatmentReview: false,
})
})
it('preserves an existing booking rate when suggesting a missing treatment', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('3041', 'Försäljning tjänst 25% sv')],
[{
account_number: '3041',
default_vat_treatment: null,
default_vat_rate: 0.12,
} as never],
)
expect(result.defaultVatTreatment).toBe('standard_25')
expect(result.defaultVatRate).toBe(0.12)
})
it('requires review for a suggested class 6 service treatment', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('6545', 'Inköp tjänster utanför EU 25%')],
[],
)
expect(result).toMatchObject({
defaultVatTreatment: 'reverse_charge_non_eu_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
})
})
})
describe('applyVatTreatmentReview', () => {
it('marks only the selected row reviewed, including class 5 and 6 accounts', () => {
const mappings = [
mapping('5010', 'Inköp tjänst EU'),
mapping('3041', 'Försäljning tjänst 25% sv'),
]
const result = applyVatTreatmentReview(
it('persists a suggestion only after an explicit row confirmation', () => {
const mappings = enrichAccountMappingsWithVat([
mapping('4056', 'Inköp varor 25% EU'),
], [])
expect(mappings[0].vatTreatmentReviewed).toBe(false)
const reviewed = applyVatTreatmentReview(
mappings,
'5010',
'reverse_charge_eu_services',
0.25,
'4056',
mappings[0].defaultVatTreatment ?? null,
mappings[0].defaultVatRate ?? null,
)
expect(result[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_services',
defaultVatRate: 0.25,
expect(reviewed[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
vatTreatmentReviewed: true,
vatTreatmentSuggested: false,
})
})
it('clears hidden review state on remap and restores it on identity mapping', () => {
const [suggested] = enrichAccountMappingsWithVat([
mapping('4056', 'Inköp varor 25% EU'),
], [])
const [remapped] = enrichAccountMappingsWithVat([{
...suggested,
targetAccount: '4010',
targetName: 'Inköp material',
}], [])
expect(remapped).toMatchObject({
defaultVatTreatment: null,
requiresVatTreatmentReview: false,
vatTreatmentReviewed: true,
})
const [identity] = enrichAccountMappingsWithVat([{
...remapped,
targetAccount: '4056',
targetName: 'Inköp varor 25% EU',
}], [])
expect(identity).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
})
})
it('preserves another row review when one mapping changes', () => {
const initial = enrichAccountMappingsWithVat([
mapping('3041', 'Försäljning tjänst 25% sv'),
mapping('4056', 'Inköp varor 25% EU'),
], [])
const reviewed = applyVatTreatmentReview(initial, '3041', 'exempt', 0)
const remapped = enrichChangedAccountMappingWithVat(
reviewed.map((item) => item.sourceAccount === '4056'
? { ...item, targetAccount: '4010', targetName: 'Inköp material' }
: item),
'4056',
[],
)
expect(remapped[0]).toMatchObject({
defaultVatTreatment: 'exempt',
defaultVatRate: 0,
vatTreatmentReviewed: true,
})
expect(result[1].vatTreatmentReviewed).toBeUndefined()
})
})
+17 -2
View File
@@ -14,7 +14,12 @@ import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { AccountMapping } from './types'
import { isAccountVatTreatment, type AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import {
defaultRateForVatTreatment,
isAccountVatTreatment,
isVatTreatmentAllowedForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
export interface AccountSyncResult {
/** Accounts inserted into chart_of_accounts */
@@ -164,9 +169,19 @@ export async function syncMappedAccounts(
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
return result
}
const accountClass = Number(mapping.targetAccount.charAt(0))
if (
mapping.defaultVatTreatment &&
!isVatTreatmentAllowedForAccountClass(mapping.defaultVatTreatment, accountClass)
) {
result.error = `VAT treatment is not valid for account ${mapping.sourceAccount}`
return result
}
vatDefaults.set(mapping.targetAccount, {
treatment: mapping.defaultVatTreatment ?? null,
rate: mapping.defaultVatRate ?? null,
rate: mapping.defaultVatTreatment && mapping.defaultVatRate == null
? defaultRateForVatTreatment(mapping.defaultVatTreatment, accountClass)
: mapping.defaultVatRate ?? null,
})
}
+24 -3
View File
@@ -19,7 +19,16 @@ export function enrichAccountMappingsWithVat(
)
return mappings.map((mapping) => {
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) {
return {
...mapping,
defaultVatTreatment: null,
defaultVatRate: null,
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
requiresVatTreatmentReview: false,
}
}
const accountClass = Number(mapping.sourceAccount.charAt(0))
if (accountClass < 3 || accountClass > 6) return mapping
@@ -39,10 +48,10 @@ export function enrichAccountMappingsWithVat(
return {
...mapping,
defaultVatTreatment: suggestion?.treatment ?? null,
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
defaultVatRate: existing?.default_vat_rate ?? suggestion?.rate ?? null,
vatTreatmentSuggested: Boolean(suggestion),
vatTreatmentReviewed: false,
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
requiresVatTreatmentReview: accountClass === 3 || accountClass === 4 || Boolean(suggestion),
}
})
}
@@ -65,3 +74,15 @@ export function applyVatTreatmentReview(
: mapping
)
}
export function enrichChangedAccountMappingWithVat(
mappings: AccountMapping[],
sourceAccount: string,
existingAccounts: BASAccount[],
): AccountMapping[] {
return mappings.map((mapping) =>
mapping.sourceAccount === sourceAccount
? enrichAccountMappingsWithVat([mapping], existingAccounts)[0]
: mapping
)
}
@@ -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>
@@ -143,14 +143,14 @@ describe('rutorFromTotals: explicit account VAT treatments', () => {
expect(rutor.ruta20).toBe(1000)
})
it('keeps a static BAS mapping authoritative over an explicit treatment', () => {
it('lets an explicit treatment replace a static BAS mapping', () => {
const totals = new Map([['3001', { debit: 0, credit: 1000 }]])
const rutor = rutorFromTotals(totals, {
mappingByAccount: new Map([['3001', { box: 'ruta42', side: 'credit' }]]),
explicitAccounts: new Set(['3001']),
})
expect(rutor.ruta05).toBe(1000)
expect(rutor.ruta42).toBe(0)
expect(rutor.ruta05).toBe(0)
expect(rutor.ruta42).toBe(1000)
})
})
+3 -2
View File
@@ -150,8 +150,9 @@ export async function findRcBasisGaps(
const basisByEntryAndRate = new Map<string, number>()
for (const line of siblingLines) {
const rate = STATIC_RC_BASIS_RATE.get(line.account_number) ??
dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
const rate = dynamicVatAccounts.explicitAccounts.has(line.account_number)
? dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
: STATIC_RC_BASIS_RATE.get(line.account_number)
if (rate) {
const key = `${line.journal_entry_id}:${rate}`
const prev = basisByEntryAndRate.get(key) || 0
+2 -1
View File
@@ -436,6 +436,7 @@ export function rutorFromTotals(
}
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
if (dynamic?.explicitAccounts.has(account)) continue
const t = totals.get(account)
if (!t) continue
const balance = mapping.side === 'credit'
@@ -445,7 +446,6 @@ export function rutorFromTotals(
}
for (const [account, mapping] of dynamic?.mappingByAccount ?? []) {
if (ACCOUNT_RUTA[account]) continue
const t = totals.get(account)
if (!t) continue
const balance = mapping.side === 'credit' ? t.credit - t.debit : t.debit - t.credit
@@ -561,6 +561,7 @@ export async function calculateVatDeclaration(
}
const RATE_BUCKET = { 0.25: 'base25', 0.12: 'base12', 0.06: 'base6' } as const
for (const [account, rate] of [['3001', 'base25'], ['3002', 'base12'], ['3003', 'base6']] as const) {
if (dynamicVatAccounts.explicitAccounts.has(account)) continue
const t = totals.get(account)
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
+2 -8
View File
@@ -2,8 +2,8 @@ import type {
VatDeclarationCheck,
VatCheckAccountTotals,
} from './vat-declaration-checks'
import type { VatDeclarationRutor } from '@/types'
import { roundOre } from '@/lib/money'
import type { VatDeclarationRutor } from '@/types'
/**
* The filing gate for the momsdeklaration: ONE derived value that the
@@ -94,12 +94,6 @@ export const RC_BASIS_ACCOUNTS_BY_RATE = {
r6: ['4517', '4537', '4533', '4417', '4427'],
} as const
const STATIC_RC_BASIS_ACCOUNTS = new Set<string>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
])
/** Net debit balance of the RC basis accounts, one figure per momssats. */
export interface RcBasisTotalsByRate {
r25: number
@@ -120,6 +114,7 @@ export function rcBasisTotalsByRate(
const sumGroup = (accounts: readonly string[]): number => {
let sum = 0
for (const account of accounts) {
if (dynamic?.explicitAccounts.has(account)) continue
const t = totals.get(account)
if (t) sum += t.debit - t.credit
}
@@ -131,7 +126,6 @@ export function rcBasisTotalsByRate(
r6: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r6),
}
for (const [account, rate] of dynamic?.rcBasisRateByAccount ?? []) {
if (STATIC_RC_BASIS_ACCOUNTS.has(account)) continue
const total = totals.get(account)
if (!total) continue
const key = rate === 0.25 ? 'r25' : rate === 0.12 ? 'r12' : 'r6'
+14 -17
View File
@@ -42,7 +42,7 @@ const emptyDynamicVatAccounts = (): DynamicVatAccounts => ({
rcBasisRateByAccount: new Map(),
})
/** Explicit treatments extend custom accounts; fixed BAS mappings stay authoritative. */
/** Explicit account treatments win; accounts without one keep BAS fallback. */
export async function fetchDynamicVatAccounts(
supabase: SupabaseClient,
companyId: string,
@@ -58,7 +58,6 @@ export async function fetchDynamicVatAccounts(
.select('account_number, account_name, account_class, default_vat_rate, default_vat_treatment')
.eq('company_id', companyId)
.in('account_class', [3, 4, 5, 6])
.not('is_active', 'is', false)
.order('account_number', { ascending: true })
.range(from, to),
)
@@ -66,27 +65,25 @@ export async function fetchDynamicVatAccounts(
const result = emptyDynamicVatAccounts()
for (const row of rows) {
const account = row.account_number
const accountClass = Number(row.account_class ?? account.charAt(0))
const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
if (isAccountVatTreatment(row.default_vat_treatment)) {
if (ACCOUNT_TO_BOX[account]) {
if (
RUTA_05_STATIC_RATE_ACCOUNTS.has(account) &&
configuredRate !== null && TAXABLE_RATES.includes(configuredRate)
) {
result.staticRateByAccount.set(account, configuredRate)
}
continue
}
const mapping = resolveVatTreatmentRuta(row.default_vat_treatment, accountClass)
if (!mapping) continue
result.explicitAccounts.add(account)
const mapping = resolveVatTreatmentRuta(
row.default_vat_treatment,
row.account_class,
row.account_number,
)
if (!mapping) continue
result.mappingByAccount.set(account, mapping)
// Always include an explicit account in the ledger filter. The Set in
// fetchVatAccountTotals deduplicates static BAS accounts, while this also
// covers accounts that exist only in the separate moms-box mirror.
result.accounts.push(account)
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass)
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, row.account_class)
if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) {
result.rateByAccount.set(account, rate)
const target = ACCOUNT_TO_BOX[account] ? result.staticRateByAccount : result.rateByAccount
target.set(account, rate)
}
if (
['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24'].includes(mapping.box) &&
@@ -97,7 +94,7 @@ export async function fetchDynamicVatAccounts(
continue
}
if (accountClass !== 3) continue
if (row.account_class !== 3) continue
const rate = configuredRate ?? inferDomesticSalesRate(account, row.account_name)
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
if (ACCOUNT_TO_BOX[account]) {
+22 -32
View File
@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
defaultRateForVatTreatment,
isVatTreatmentValidForAccountClass,
resolveVatTreatmentRuta,
suggestVatTreatment,
vatTreatmentsForAccountClass,
} from '../account-vat-treatment'
describe('resolveVatTreatmentRuta', () => {
@@ -23,23 +21,15 @@ describe('resolveVatTreatmentRuta', () => {
it('maps purchase treatments by purchase class', () => {
expect(resolveVatTreatmentRuta('reverse_charge_eu_goods', 4)).toEqual({ box: 'ruta20', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_eu_services', 4)).toEqual({ box: 'ruta21', side: 'debit' })
expect(resolveVatTreatmentRuta('export_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_non_eu_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4)).toEqual({ box: 'ruta23', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4, '4425')).toEqual({ box: 'ruta24', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 5)).toEqual({ box: 'ruta24', side: 'debit' })
expect(resolveVatTreatmentRuta('export_goods', 4)).toEqual({ box: 'ruta50', side: 'debit' })
expect(resolveVatTreatmentRuta('export_goods', 4)).toBeNull()
expect(resolveVatTreatmentRuta('exempt', 4)).toBeNull()
})
})
describe('vat treatment applicability', () => {
it('exposes only treatments that resolve for the account class', () => {
expect(vatTreatmentsForAccountClass(3)).toContain('vmb')
expect(vatTreatmentsForAccountClass(5)).not.toContain('vmb')
expect(isVatTreatmentValidForAccountClass('reverse_charge_eu_services', 5)).toBe(true)
expect(isVatTreatmentValidForAccountClass('standard_25', 5)).toBe(false)
})
})
describe('suggestVatTreatment', () => {
it('suggests the issue examples from labels, not SIE metadata', () => {
expect(suggestVatTreatment('3041', 'Försäljning tjänst 25% sv')).toEqual({
@@ -55,30 +45,30 @@ describe('suggestVatTreatment', () => {
expect(suggestVatTreatment('4056', 'Projektkostnad')).toBeNull()
})
it('matches EU as a term, not a substring inside another word', () => {
expect(suggestVatTreatment('4056', 'Reumatologiska varor 25%')).toBeNull()
expect(suggestVatTreatment('4056', 'Inköp EU-varor 25%')).toEqual({
treatment: 'reverse_charge_eu_goods',
rate: 0.25,
it('does not suggest an unsupported purchase treatment for imports of goods', () => {
expect(suggestVatTreatment('4545', 'Import varor utanför EU 25%')).toBeNull()
})
it('checks outside-EU labels before the generic EU matcher', () => {
expect(suggestVatTreatment('3048', 'Export tjänster utanför EU')).toEqual({
treatment: 'export_services', rate: 0,
})
expect(suggestVatTreatment('3108', 'Försäljning varor till annat EU-land, momsfri')).toEqual({
treatment: 'reverse_charge_eu_goods', rate: 0,
})
expect(suggestVatTreatment('6545', 'Inköp tjänster utanför EU 25%')).toEqual({
treatment: 'reverse_charge_non_eu_services', rate: 0.25,
})
})
it('does not assume a purchase-side reverse-charge rate', () => {
expect(defaultRateForVatTreatment('reverse_charge_eu_goods', 4)).toBeNull()
expect(defaultRateForVatTreatment('reverse_charge_eu_services', 5)).toBeNull()
expect(defaultRateForVatTreatment('reverse_charge_domestic', 6)).toBeNull()
expect(defaultRateForVatTreatment('export_goods', 4)).toBeNull()
expect(suggestVatTreatment('4056', 'Inköp varor EU')).toEqual({
treatment: 'reverse_charge_eu_goods',
rate: null,
})
it('does not match EU inside an unrelated word', () => {
expect(suggestVatTreatment('4010', 'Reumatologiska varor')).toBeNull()
})
it('does not use a gross account rate for VMB', () => {
it('keeps VMB without a generic booking rate', () => {
expect(suggestVatTreatment('3211', 'Försäljning VMB')).toEqual({
treatment: 'vmb', rate: null,
})
expect(defaultRateForVatTreatment('vmb', 3)).toBeNull()
expect(suggestVatTreatment('3021', 'Försäljning begagnat 25% VMB')).toEqual({
treatment: 'vmb',
rate: null,
})
})
})
+33 -28
View File
@@ -3,11 +3,12 @@ import type { VatDeclarationRutor } from '@/types'
export const ACCOUNT_VAT_TREATMENTS = [
'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',
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
] as const
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
export type AccountVatRate = 0 | 0.06 | 0.12 | 0.25 | null
export interface AccountVatRutaMapping {
box: keyof VatDeclarationRutor
@@ -18,6 +19,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
standard_25: 'ruta05', reduced_12: 'ruta05', reduced_6: 'ruta05',
exempt: 'ruta42', reverse_charge_domestic: 'ruta41',
reverse_charge_eu_goods: 'ruta35', reverse_charge_eu_services: 'ruta39',
reverse_charge_non_eu_services: null,
export_goods: 'ruta36', export_services: 'ruta40', vmb: 'ruta07',
rental_voluntary: 'ruta08',
}
@@ -25,6 +27,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
export function resolveVatTreatmentRuta(
treatment: AccountVatTreatment,
accountClass: number,
accountNumber?: string,
): AccountVatRutaMapping | null {
if (accountClass === 3) {
const box = REVENUE_RUTA[treatment]
@@ -33,25 +36,40 @@ export function resolveVatTreatmentRuta(
if (accountClass < 4 || accountClass > 6) return null
if (treatment === 'reverse_charge_eu_goods') return { box: 'ruta20', side: 'debit' }
if (treatment === 'reverse_charge_eu_services') return { box: 'ruta21', side: 'debit' }
if (treatment === 'export_services') return { box: 'ruta22', side: 'debit' }
if (treatment === 'reverse_charge_non_eu_services') return { box: 'ruta22', side: 'debit' }
if (treatment === 'reverse_charge_domestic') {
return { box: accountClass === 4 ? 'ruta23' : 'ruta24', side: 'debit' }
const isKnownServiceAccount = accountNumber != null && /^442[567]$/.test(accountNumber)
return { box: accountClass === 4 && !isKnownServiceAccount ? 'ruta23' : 'ruta24', side: 'debit' }
}
if (treatment === 'export_goods') return { box: 'ruta50', side: 'debit' }
return null
}
export function isVatTreatmentAllowedForAccountClass(
treatment: AccountVatTreatment,
accountClass: number,
): boolean {
return resolveVatTreatmentRuta(treatment, accountClass) !== null
}
export function vatTreatmentsForAccountClass(accountClass: number | null): AccountVatTreatment[] {
if (accountClass === null) return []
return ACCOUNT_VAT_TREATMENTS.filter((treatment) =>
isVatTreatmentAllowedForAccountClass(treatment, accountClass)
)
}
export function defaultRateForVatTreatment(
treatment: AccountVatTreatment,
accountClass: number,
): number | null {
): AccountVatRate {
if (treatment === 'standard_25') return 0.25
if (treatment === 'reduced_12') return 0.12
if (treatment === 'reduced_6') return 0.06
if (treatment === 'exempt') return 0
if (treatment === 'rental_voluntary') return 0.25
if (treatment === 'vmb') return null
return accountClass >= 4 && accountClass <= 6 ? null : 0
if (treatment === 'rental_voluntary') return 0.25
if (treatment === 'export_goods' || treatment === 'export_services') return 0
return accountClass >= 4 && accountClass <= 6 ? 0.25 : 0
}
export function isAccountVatTreatment(value: unknown): value is AccountVatTreatment {
@@ -59,19 +77,6 @@ export function isAccountVatTreatment(value: unknown): value is AccountVatTreatm
(ACCOUNT_VAT_TREATMENTS as readonly string[]).includes(value)
}
export function vatTreatmentsForAccountClass(accountClass: number): AccountVatTreatment[] {
return ACCOUNT_VAT_TREATMENTS.filter(
(treatment) => resolveVatTreatmentRuta(treatment, accountClass) !== null,
)
}
export function isVatTreatmentValidForAccountClass(
treatment: AccountVatTreatment,
accountClass: number,
): boolean {
return resolveVatTreatmentRuta(treatment, accountClass) !== null
}
export interface SuggestedVatTreatment {
treatment: AccountVatTreatment
rate: number | null
@@ -90,19 +95,17 @@ export function suggestVatTreatment(
if (accountClass < 3 || accountClass > 6) return null
const name = accountName.toLocaleLowerCase('sv-SE')
const percent = /\b(25|12|6)\s*%/.exec(name)
const rate = percent ? Number(percent[1]) / 100 : null
const rate = percent ? Number(percent[1]) / 100 : 0.25
if (accountClass === 3) {
if (/vmb|vinstmarginal/.test(name)) return { treatment: 'vmb', rate: null }
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) {
return { treatment: 'rental_voluntary', rate: rate ?? 0.25 }
}
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) return { treatment: 'rental_voluntary', rate }
if (/omvänd/.test(name)) return { treatment: 'reverse_charge_domestic', rate: 0 }
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
if (/export|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate: 0 }
if (/export|utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate: 0 }
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate: 0 }
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate: 0 }
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
if (/försälj|forsalj|intäkt|intakt/.test(name) && percent) {
return {
treatment: rate === 0.12 ? 'reduced_12' : rate === 0.06 ? 'reduced_6' : 'standard_25',
@@ -113,8 +116,10 @@ export function suggestVatTreatment(
}
if (/omvänd/.test(name) && /sverige|svensk|inrikes/.test(name)) return { treatment: 'reverse_charge_domestic', rate }
if (/import|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate }
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate }
if (/utanför eu|import/.test(name) && /var/.test(name)) return null
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) {
return { treatment: 'reverse_charge_non_eu_services', rate }
}
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate }
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate }
return null
+2 -1
View File
@@ -4987,7 +4987,8 @@
"vat_treatment_reverse_charge_domestic": "Domestic reverse charge",
"vat_treatment_reverse_charge_eu_goods": "EU goods",
"vat_treatment_reverse_charge_eu_services": "EU services",
"vat_treatment_export_goods": "Export or import of goods",
"vat_treatment_reverse_charge_non_eu_services": "Services purchased outside the EU",
"vat_treatment_export_goods": "Goods exported outside the EU",
"vat_treatment_export_services": "Services outside the EU",
"vat_treatment_vmb": "Margin scheme (box 07)",
"vat_treatment_rental_voluntary": "Voluntary VAT on rental income (box 08)",
+2 -1
View File
@@ -4987,7 +4987,8 @@
"vat_treatment_reverse_charge_domestic": "Omvänd skattskyldighet Sverige",
"vat_treatment_reverse_charge_eu_goods": "EU-varor",
"vat_treatment_reverse_charge_eu_services": "EU-tjänster",
"vat_treatment_export_goods": "Export eller import av varor",
"vat_treatment_reverse_charge_non_eu_services": "Inköp av tjänster utanför EU",
"vat_treatment_export_goods": "Export av varor utanför EU",
"vat_treatment_export_services": "Tjänster utanför EU",
"vat_treatment_vmb": "Vinstmarginalbeskattning (ruta 07)",
"vat_treatment_rental_voluntary": "Frivillig moms på uthyrning (ruta 08)",
+1 -1
View File
@@ -7,7 +7,7 @@
]
},
"naiveOreRound": {
"count": 631
"count": 630
},
"handRolledInvariants": {
"count": 115
+1 -1
View File
@@ -30,7 +30,7 @@ Response `200`:
```ts
{
data: {
accounts: { account_number: string, account_name: string, account_class: number, account_group: string, account_type: string, normal_balance: string, is_system_account: boolean, is_active: boolean, description: string, default_vat_code: string, sru_code: string, sort_order: number }[]
accounts: { account_number: string, account_name: string, account_class: number, account_group: string, account_type: string, normal_balance: string, is_system_account: boolean, is_active: boolean, description: string, default_vat_code: string, default_vat_rate: number, default_vat_treatment: string, sru_code: string, sort_order: number }[]
},
meta: {
request_id: string,
@@ -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()
}
})
})