diff --git a/DECISIONS.md b/DECISIONS.md index b7101c32..5379f6bf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -946,6 +946,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 29c66c11..58f9f955 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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} /> )} diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts index 93144db8..177fd658 100644 --- a/app/api/bookkeeping/accounts/[number]/route.ts +++ b/app/api/bookkeeping/accounts/[number]/route.ts @@ -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 }) diff --git a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts index 54530ce9..b5c2c864 100644 --- a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts +++ b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts @@ -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 diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts index 62f95ba0..5734d84f 100644 --- a/app/api/bookkeeping/accounts/route.ts +++ b/app/api/bookkeeping/accounts/route.ts @@ -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), diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts index 40196bbd..b8fa11f3 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -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) } diff --git a/app/api/v1/companies/[companyId]/accounts/route.ts b/app/api/v1/companies/[companyId]/accounts/route.ts index cab15a34..c64349ae 100644 --- a/app/api/v1/companies/[companyId]/accounts/route.ts +++ b/app/api/v1/companies/[companyId]/accounts/route.ts @@ -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', diff --git a/components/bookkeeping/AccountVatTreatmentSelect.tsx b/components/bookkeeping/AccountVatTreatmentSelect.tsx index 16cb5313..2be98172 100644 --- a/components/bookkeeping/AccountVatTreatmentSelect.tsx +++ b/components/bookkeeping/AccountVatTreatmentSelect.tsx @@ -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 (
diff --git a/components/bookkeeping/AddAccountDialog.tsx b/components/bookkeeping/AddAccountDialog.tsx index eac2b7b7..3f48b4b0 100644 --- a/components/bookkeeping/AddAccountDialog.tsx +++ b/components/bookkeeping/AddAccountDialog.tsx @@ -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)) } }} /> diff --git a/components/bookkeeping/EditAccountDialog.tsx b/components/bookkeeping/EditAccountDialog.tsx index a470b63d..05e89098 100644 --- a/components/bookkeeping/EditAccountDialog.tsx +++ b/components/bookkeeping/EditAccountDialog.tsx @@ -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)) } }} /> diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index cce5d617..fa87d258 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -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(null) + const companyAccountsForVatRef = useRef([]) // Options state const [migrationOptions, setMigrationOptions] = useState(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')} /> )} diff --git a/components/import/AccountMappingStep.tsx b/components/import/AccountMappingStep.tsx index 68704ff3..b41cd4fd 100644 --- a/components/import/AccountMappingStep.tsx +++ b/components/import/AccountMappingStep.tsx @@ -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({ Källkonto Källnamn - {t('vat_treatment_column')} Målkonto + {t('vat_treatment_column')} Konfidens @@ -255,8 +255,43 @@ export default function AccountMappingStep({ - {onVatTreatmentChange && - mapping.sourceAccount === mapping.targetAccount && + + + + {mapping.sourceAccount === mapping.targetAccount && ['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
{ - const account = basAccounts.find((a) => a.account_number === value) - onMappingChange( - mapping.sourceAccount, - value === 'none' ? '' : value, - account?.account_name || '' - ) - }} - > - - - - - -- Välj konto -- - {Object.entries(accountsByClass).map(([className, accounts]) => ( -
-
- {className} -
- {accounts.map((account) => ( - - {account.account_number} - {account.account_name} - - ))} -
- ))} -
- - {mapping.targetAccount && (
diff --git a/extensions/general/mcp-server/__tests__/account-tools.test.ts b/extensions/general/mcp-server/__tests__/account-tools.test.ts index 1b458b60..6eb96d4c 100644 --- a/extensions/general/mcp-server/__tests__/account-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/account-tools.test.ts @@ -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 } } + + 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 } } + + expect(result.preview.changes).toEqual({ default_vat_treatment: null }) + }) }) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index adee27e3..81efe426 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -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) }) }) diff --git a/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts b/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts index b260ac18..7d3d81f2 100644 --- a/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts @@ -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 diff --git a/extensions/general/mcp-server/__tests__/vat-declaration-validate-completeness.test.ts b/extensions/general/mcp-server/__tests__/vat-declaration-validate-completeness.test.ts index bf717f47..0035e72c 100644 --- a/extensions/general/mcp-server/__tests__/vat-declaration-validate-completeness.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-declaration-validate-completeness.test.ts @@ -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 @@ -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 = {}): VatDeclarationRu function setDeclaration( rutor: VatDeclarationRutor, rcInput?: Record, + 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({ diff --git a/extensions/general/mcp-server/resources/chart-of-accounts.ts b/extensions/general/mcp-server/resources/chart-of-accounts.ts index 6cf7b305..1c485d30 100644 --- a/extensions/general/mcp-server/resources/chart-of-accounts.ts +++ b/extensions/general/mcp-server/resources/chart-of-accounts.ts @@ -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(({ 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) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 2bee8cab..b1cea82f 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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> /** * 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( + 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>, + rcBasisByRate?: { r25: number; r12: number; r6: number }, ): Promise { 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 = { 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 = { account_number: accountNumber } const changes: Record = {} - 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) diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 92061699..6eac3ffc 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -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) }) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 76776418..4046745d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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({ diff --git a/lib/import/__tests__/account-sync.test.ts b/lib/import/__tests__/account-sync.test.ts index 035636bf..551d681f 100644 --- a/lib/import/__tests__/account-sync.test.ts +++ b/lib/import/__tests__/account-sync.test.ts @@ -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() diff --git a/lib/import/__tests__/account-vat-treatment.test.ts b/lib/import/__tests__/account-vat-treatment.test.ts index 21e8772f..f3e1fb1e 100644 --- a/lib/import/__tests__/account-vat-treatment.test.ts +++ b/lib/import/__tests__/account-vat-treatment.test.ts @@ -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() }) }) diff --git a/lib/import/account-sync.ts b/lib/import/account-sync.ts index 9a6e5a30..553437ec 100644 --- a/lib/import/account-sync.ts +++ b/lib/import/account-sync.ts @@ -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, }) } diff --git a/lib/import/account-vat-treatment.ts b/lib/import/account-vat-treatment.ts index 8fb79f64..bd2ccace 100644 --- a/lib/import/account-vat-treatment.ts +++ b/lib/import/account-vat-treatment.ts @@ -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 + ) +} diff --git a/lib/pending-operations/__tests__/account-and-note-executors.test.ts b/lib/pending-operations/__tests__/account-and-note-executors.test.ts index 2548a8f4..ea77eaae 100644 --- a/lib/pending-operations/__tests__/account-and-note-executors.test.ts +++ b/lib/pending-operations/__tests__/account-and-note-executors.test.ts @@ -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 diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index d0054312..56bc7bd1 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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 } } diff --git a/lib/pending-operations/schemas/account.ts b/lib/pending-operations/schemas/account.ts index d3837171..c5b819b8 100644 --- a/lib/pending-operations/schemas/account.ts +++ b/lib/pending-operations/schemas/account.ts @@ -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 diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index fc83d85e..387c332a 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -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) }) }) diff --git a/lib/reports/rc-basis-gaps.ts b/lib/reports/rc-basis-gaps.ts index d149ff82..883288bf 100644 --- a/lib/reports/rc-basis-gaps.ts +++ b/lib/reports/rc-basis-gaps.ts @@ -150,8 +150,9 @@ export async function findRcBasisGaps( const basisByEntryAndRate = new Map() 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 diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index fbdcce1e..a7dd7957 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -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) } diff --git a/lib/reports/vat-filing-gate.ts b/lib/reports/vat-filing-gate.ts index 8d1ad84a..0a66d3ab 100644 --- a/lib/reports/vat-filing-gate.ts +++ b/lib/reports/vat-filing-gate.ts @@ -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([ - ...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' diff --git a/lib/reports/vat-revenue-accounts.ts b/lib/reports/vat-revenue-accounts.ts index 15da8668..d3bfc751 100644 --- a/lib/reports/vat-revenue-accounts.ts +++ b/lib/reports/vat-revenue-accounts.ts @@ -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]) { diff --git a/lib/vat/__tests__/account-vat-treatment.test.ts b/lib/vat/__tests__/account-vat-treatment.test.ts index 1f903e90..404e2654 100644 --- a/lib/vat/__tests__/account-vat-treatment.test.ts +++ b/lib/vat/__tests__/account-vat-treatment.test.ts @@ -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, - }) }) }) diff --git a/lib/vat/account-vat-treatment.ts b/lib/vat/account-vat-treatment.ts index 30d513ad..b96f59f2 100644 --- a/lib/vat/account-vat-treatment.ts +++ b/lib/vat/account-vat-treatment.ts @@ -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 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 diff --git a/messages/en.json b/messages/en.json index b9217f57..adbce97b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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)", diff --git a/messages/sv.json b/messages/sv.json index 50735c69..b16b7e78 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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)", diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index eb410f03..479fdcfd 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -7,7 +7,7 @@ ] }, "naiveOreRound": { - "count": 631 + "count": 630 }, "handRolledInvariants": { "count": 115 diff --git a/skills/accounted-api/references/periods.md b/skills/accounted-api/references/periods.md index 283e5564..d22cbb67 100644 --- a/skills/accounted-api/references/periods.md +++ b/skills/accounted-api/references/periods.md @@ -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, diff --git a/supabase/migrations/20260815150000_drop_legacy_account_vat_treatment_constraint.sql b/supabase/migrations/20260815150000_drop_legacy_account_vat_treatment_constraint.sql new file mode 100644 index 00000000..ae84cdc1 --- /dev/null +++ b/supabase/migrations/20260815150000_drop_legacy_account_vat_treatment_constraint.sql @@ -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; diff --git a/supabase/migrations/20260815150100_normalize_legacy_account_vat_treatments.sql b/supabase/migrations/20260815150100_normalize_legacy_account_vat_treatments.sql new file mode 100644 index 00000000..fdcf97c0 --- /dev/null +++ b/supabase/migrations/20260815150100_normalize_legacy_account_vat_treatments.sql @@ -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'; diff --git a/supabase/migrations/20260815150200_clear_incompatible_account_vat_treatments.sql b/supabase/migrations/20260815150200_clear_incompatible_account_vat_treatments.sql new file mode 100644 index 00000000..bd7a25a5 --- /dev/null +++ b/supabase/migrations/20260815150200_clear_incompatible_account_vat_treatments.sql @@ -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' + ) + ) + ); diff --git a/supabase/migrations/20260815150300_enforce_class_aware_account_vat_treatment.sql b/supabase/migrations/20260815150300_enforce_class_aware_account_vat_treatment.sql new file mode 100644 index 00000000..002c7cdc --- /dev/null +++ b/supabase/migrations/20260815150300_enforce_class_aware_account_vat_treatment.sql @@ -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'; diff --git a/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts b/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts index 2f2dc01d..92218a1d 100644 --- a/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts +++ b/supabase/migrations/__tests__/account-default-vat-treatment.pg.test.ts @@ -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() + } }) })