fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement * docs(api): refresh account endpoint skill * fix(mcp): preserve ruta 05 compatibility * test(vat): seed migration constraint fixtures * docs(vat): clarify treatment precedence --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -946,6 +946,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end.
|
||||
[2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment.
|
||||
[2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales.
|
||||
[2026-08-13] Correct the shipped VAT-treatment constraint in a new migration: the predecessor PR reached main while the replacement review was active, so the immutable original migration stays byte-identical and the class-aware vocabulary is enforced additively with NOT VALID plus validation.
|
||||
[2026-08-13] Issue #1457 supersedes the stale canonical-account exception above: an explicit per-account VAT treatment always overrides BAS fallback, while a reverse-charge treatment derives the editable 25 percent standard rate only when no booking default exists; class 5-6 SIE accounts require review when a treatment is actually suggested, not for every ordinary expense account.
|
||||
[2026-08-13] AGI needs two SKV scopes, not one: added `agdredovisningperiod` (hanteraredovisningsperiod: kvittenser/las) alongside `agd` (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with `agd` alone fails only at the last step.
|
||||
[2026-08-13] Reversed the #1250 rule that the APIGW "required scopes are not authorized" body must name only the subscription: production proved the same body also fires when the TOKEN lacks the scope (AGI kvittens needed agdredovisningperiod), so naming one cause and hiding the other presents a coin flip as a diagnosis. The message now names both knobs plus the refused API path, and system mode points at SKATTEVERKET_SYSTEM_SCOPES again. Classification is unchanged (ACCESS_DENIED, still not a reconsent code): a reconnect only helps once the scope exists, so it can never be automatic.
|
||||
[2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue.
|
||||
|
||||
@@ -65,6 +65,7 @@ import type {
|
||||
} from '@/lib/import/types'
|
||||
import {
|
||||
applyVatTreatmentReview,
|
||||
enrichChangedAccountMappingWithVat,
|
||||
enrichAccountMappingsWithVat,
|
||||
} from '@/lib/import/account-vat-treatment'
|
||||
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
|
||||
@@ -604,15 +605,14 @@ function SIEImportWizard() {
|
||||
setIssues(data.parsed.issues)
|
||||
setSieAccounts(data.parsed.accounts)
|
||||
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
|
||||
} else {
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, []))
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
if (!accountsRes.ok) {
|
||||
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
||||
}
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
|
||||
|
||||
setStep('preview')
|
||||
|
||||
@@ -704,11 +704,19 @@ function SIEImportWizard() {
|
||||
}, [file, handleFileSelect, toast])
|
||||
|
||||
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
|
||||
setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName))
|
||||
setMappings((prev) => enrichChangedAccountMappingWithVat(
|
||||
applyMappingOverride(prev, sourceAccount, targetAccount, targetName),
|
||||
sourceAccount,
|
||||
basAccounts,
|
||||
))
|
||||
|
||||
setPreview((prev) => {
|
||||
if (!prev) return prev
|
||||
const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName)
|
||||
const updatedMappings = enrichChangedAccountMappingWithVat(
|
||||
applyMappingOverride(mappings, sourceAccount, targetAccount, targetName),
|
||||
sourceAccount,
|
||||
basAccounts,
|
||||
)
|
||||
const mapped = updatedMappings.filter((m) => m.targetAccount).length
|
||||
const unmapped = updatedMappings.length - mapped
|
||||
const lowConfidence = updatedMappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
|
||||
@@ -723,7 +731,7 @@ function SIEImportWizard() {
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [mappings])
|
||||
}, [basAccounts, mappings])
|
||||
|
||||
const handleVatTreatmentChange = useCallback((
|
||||
sourceAccount: string,
|
||||
@@ -771,13 +779,25 @@ function SIEImportWizard() {
|
||||
|
||||
toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` })
|
||||
|
||||
// Optimistically update mappings: mark created accounts as self-mapped
|
||||
const createdSet = new Set(missingAccounts.map(a => a.number))
|
||||
setMappings(prev => enrichAccountMappingsWithVat(prev.map(m =>
|
||||
!m.targetAccount && createdSet.has(m.sourceAccount)
|
||||
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
|
||||
: m
|
||||
), basAccounts))
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
if (!accountsRes.ok) {
|
||||
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
||||
}
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(prev => {
|
||||
let updated = prev.map(m =>
|
||||
!m.targetAccount && createdSet.has(m.sourceAccount)
|
||||
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
|
||||
: m
|
||||
)
|
||||
for (const sourceAccount of createdSet) {
|
||||
updated = enrichChangedAccountMappingWithVat(updated, sourceAccount, accounts)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
setPreview(prev => {
|
||||
if (!prev) return prev
|
||||
const newMapped = prev.mappingStatus.mapped + createdSet.size
|
||||
@@ -790,21 +810,12 @@ function SIEImportWizard() {
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Also refresh BAS accounts list
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
setBasAccounts(accounts)
|
||||
setMappings(prev => enrichAccountMappingsWithVat(prev, accounts))
|
||||
}
|
||||
} catch (err) {
|
||||
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCreatingAccounts(false)
|
||||
}
|
||||
}, [basAccounts, missingAccounts, toast])
|
||||
}, [missingAccounts, toast])
|
||||
|
||||
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
|
||||
if (!file) { setError('No file selected'); return }
|
||||
@@ -941,8 +952,8 @@ function SIEImportWizard() {
|
||||
preview={preview} theaterModel={theaterModel}
|
||||
unresolvedVatAccountCount={mappings.filter((mapping) =>
|
||||
mapping.sourceAccount === mapping.targetAccount &&
|
||||
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) &&
|
||||
!mapping.vatTreatmentReviewed
|
||||
['3', '4'].includes(mapping.sourceAccount.charAt(0)) &&
|
||||
!mapping.defaultVatTreatment
|
||||
).length} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { sparsePatchBody } from '@/lib/api/sparse-patch'
|
||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// DELETE hard-deletes an unused, non-system account; accounts referenced by
|
||||
// this company's journal entries must be deactivated instead (PUT is_active).
|
||||
@@ -104,19 +107,44 @@ export const PUT = withRouteContext(
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const accountClass = parseInt(number[0])
|
||||
|
||||
if (
|
||||
body.default_vat_treatment &&
|
||||
!isVatTreatmentValidForAccountClass(
|
||||
body.default_vat_treatment,
|
||||
Number(number.charAt(0)),
|
||||
)
|
||||
!isVatTreatmentAllowedForAccountClass(body.default_vat_treatment, accountClass)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momshanteringen är inte giltig för kontoklassen' },
|
||||
{ error: 'Momskoden kan inte användas för den här kontoklassen.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (body.default_vat_treatment && body.default_vat_rate === undefined) {
|
||||
const { data: current, error: currentError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('default_vat_rate')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
.single()
|
||||
|
||||
if (currentError) {
|
||||
if (currentError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: getUserErrorMessage(currentError) }, { status: 500 })
|
||||
}
|
||||
|
||||
if (current.default_vat_rate == null) {
|
||||
body.default_vat_rate = defaultRateForVatTreatment(
|
||||
body.default_vat_treatment,
|
||||
accountClass,
|
||||
)
|
||||
}
|
||||
} else if (body.default_vat_treatment && body.default_vat_rate === null) {
|
||||
body.default_vat_rate = defaultRateForVatTreatment(
|
||||
body.default_vat_treatment,
|
||||
accountClass,
|
||||
)
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
|
||||
|
||||
@@ -289,6 +289,42 @@ describe('POST /api/bookkeeping/accounts', () => {
|
||||
}
|
||||
expect(insertArg.default_vat_treatment).toBe('reverse_charge_eu_goods')
|
||||
})
|
||||
|
||||
it('derives the booking rate when a treatment is set without one', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: { account_number: '4056' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account_number: '4056',
|
||||
account_name: 'Inköp varor EU',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
},
|
||||
})
|
||||
expect((await createPOST(req, routeParams)).status).toBe(200)
|
||||
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
|
||||
default_vat_rate?: number | null
|
||||
}
|
||||
expect(insertArg.default_vat_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
it('rejects a treatment that cannot apply to the account class', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account_number: '4056',
|
||||
account_name: 'Inköp varor EU',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'standard_25',
|
||||
},
|
||||
})
|
||||
expect((await createPOST(req, routeParams)).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
|
||||
@@ -398,10 +434,11 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
||||
expect(updateArg?.default_vat_rate).toBe(0)
|
||||
})
|
||||
|
||||
it('forwards default_vat_treatment into the update', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([{
|
||||
data: { account_number: '3041', default_vat_treatment: 'standard_25' },
|
||||
}])
|
||||
it('preserves an existing booking rate when updating only the treatment', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { default_vat_rate: 0.12 } },
|
||||
{ data: { account_number: '3041', default_vat_treatment: 'standard_25' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/3041', {
|
||||
method: 'PUT',
|
||||
@@ -410,19 +447,34 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
||||
expect((await PUT(req, { params: Promise.resolve({ number: '3041' }) })).status).toBe(200)
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
||||
default_vat_treatment?: string | null
|
||||
default_vat_rate?: number | null
|
||||
}
|
||||
expect(updateArg.default_vat_treatment).toBe('standard_25')
|
||||
expect(updateArg.default_vat_rate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a VAT treatment that does not apply to the account class', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([])
|
||||
it('derives the booking rate when treatment is updated and the stored rate is unset', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { default_vat_rate: null } },
|
||||
{
|
||||
data: {
|
||||
account_number: '4056',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
default_vat_rate: 0.25,
|
||||
},
|
||||
},
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/4056', {
|
||||
method: 'PUT',
|
||||
body: { default_vat_treatment: 'standard_25' },
|
||||
body: { default_vat_treatment: 'reverse_charge_eu_goods' },
|
||||
})
|
||||
expect((await PUT(req, numberParams)).status).toBe(400)
|
||||
expect(calls.some((call) => call.method === 'update')).toBe(false)
|
||||
|
||||
expect((await PUT(req, { params: Promise.resolve({ number: '4056' }) })).status).toBe(200)
|
||||
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
||||
default_vat_rate?: number | null
|
||||
}
|
||||
expect(updateArg.default_vat_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
// The body is spread straight into .update(), so the write set must be
|
||||
|
||||
@@ -6,6 +6,10 @@ import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { CreateAccountSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
|
||||
// (import, supplier-invoices, article form) consume the list directly.
|
||||
@@ -87,6 +91,19 @@ export const POST = withRouteContext(
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const accountClass = parseInt(body.account_number[0])
|
||||
if (
|
||||
body.default_vat_treatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(body.default_vat_treatment, accountClass)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momskoden kan inte användas för den här kontoklassen.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const defaultVatRate = body.default_vat_treatment && body.default_vat_rate == null
|
||||
? defaultRateForVatTreatment(body.default_vat_treatment, accountClass)
|
||||
: body.default_vat_rate ?? null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
@@ -95,7 +112,7 @@ export const POST = withRouteContext(
|
||||
company_id: companyId,
|
||||
account_number: body.account_number,
|
||||
account_name: body.account_name,
|
||||
account_class: parseInt(body.account_number[0]),
|
||||
account_class: accountClass,
|
||||
account_group: body.account_number.substring(0, 2),
|
||||
account_type: body.account_type,
|
||||
normal_balance: body.normal_balance,
|
||||
@@ -103,7 +120,7 @@ export const POST = withRouteContext(
|
||||
is_system_account: false,
|
||||
description: body.description || null,
|
||||
default_vat_code: body.default_vat_code || null,
|
||||
default_vat_rate: body.default_vat_rate ?? null,
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: body.default_vat_treatment ?? null,
|
||||
sru_code: body.sru_code || null,
|
||||
sort_order: parseInt(body.account_number),
|
||||
|
||||
@@ -42,14 +42,15 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
|
||||
|
||||
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
|
||||
|
||||
// Invert the effective mapping. Fixed BAS mappings stay authoritative;
|
||||
// explicit treatments add custom accounts only.
|
||||
// Invert the effective mapping. Explicit account treatments replace the
|
||||
// fixed BAS mapping and can move a standard account to another ruta.
|
||||
const accountsForRuta = Object.entries(ACCOUNT_RUTA)
|
||||
.filter(([, m]) => m.box === rutaKey)
|
||||
.filter(([account, m]) =>
|
||||
m.box === rutaKey && !dynamicVatAccounts.explicitAccounts.has(account)
|
||||
)
|
||||
.map(([acc]) => acc)
|
||||
|
||||
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
|
||||
if (ACCOUNT_RUTA[account]) continue
|
||||
if (mapping.box === rutaKey) accountsForRuta.push(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ const Account = z.object({
|
||||
is_active: z.boolean(),
|
||||
description: z.string().nullable(),
|
||||
default_vat_code: z.string().nullable(),
|
||||
default_vat_rate: z.number().nullable(),
|
||||
default_vat_treatment: z.string().nullable(),
|
||||
sru_code: z.string().nullable(),
|
||||
sort_order: z.number().int(),
|
||||
})
|
||||
@@ -32,7 +34,7 @@ const AccountsResponse = dataEnvelope(z.object({ accounts: z.array(Account) }))
|
||||
const ACCOUNT_COLUMNS =
|
||||
'account_number, account_name, account_class, account_group, account_type, ' +
|
||||
'normal_balance, is_system_account, is_active, description, default_vat_code, ' +
|
||||
'sru_code, sort_order'
|
||||
'default_vat_rate, default_vat_treatment, sru_code, sort_order'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'accounts.list',
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AccountVatTreatmentSelect({
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const isRelevant = accountClass === 3 ||
|
||||
(accountClass !== null && accountClass >= 4 && accountClass <= 6)
|
||||
const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass)
|
||||
const treatments = vatTreatmentsForAccountClass(accountClass)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -327,9 +327,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
accountClass={account.account_class}
|
||||
onValueChange={(treatment) => {
|
||||
setDefaultVatTreatment(treatment)
|
||||
if (treatment !== 'none') {
|
||||
if (treatment !== 'none' && defaultVatRate === 'none') {
|
||||
const rate = defaultRateForVatTreatment(treatment, account.account_class)
|
||||
setDefaultVatRate(rate === null ? 'none' : String(rate))
|
||||
if (rate !== null) setDefaultVatRate(String(rate))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -239,8 +239,14 @@ interface MigrationResults {
|
||||
import AccountMappingStep from '@/components/import/AccountMappingStep'
|
||||
import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater'
|
||||
import TheaterCanvas from '@/components/import/TheaterCanvas'
|
||||
import {
|
||||
applyVatTreatmentReview,
|
||||
enrichChangedAccountMappingWithVat,
|
||||
enrichAccountMappingsWithVat,
|
||||
} from '@/lib/import/account-vat-treatment'
|
||||
import type { TheaterModel } from '@/lib/import/theater-model'
|
||||
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
|
||||
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
@@ -962,6 +968,7 @@ function MappingStep({
|
||||
error,
|
||||
errorDetails,
|
||||
onMappingChange,
|
||||
onVatTreatmentChange,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: {
|
||||
@@ -970,6 +977,11 @@ function MappingStep({
|
||||
error: string | null
|
||||
errorDetails: string[] | null
|
||||
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
|
||||
onVatTreatmentChange: (
|
||||
sourceAccount: string,
|
||||
treatment: AccountVatTreatment | null,
|
||||
rate: number | null,
|
||||
) => void
|
||||
onContinue: () => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
@@ -1012,6 +1024,7 @@ function MappingStep({
|
||||
mappings={sieData.mappings}
|
||||
basAccounts={sieData.basAccounts}
|
||||
onMappingChange={onMappingChange}
|
||||
onVatTreatmentChange={onVatTreatmentChange}
|
||||
onContinue={onContinue}
|
||||
onBack={onBack}
|
||||
/>
|
||||
@@ -1972,6 +1985,7 @@ export default function ArcimMigrationWorkspace({
|
||||
|
||||
// SIE data state (held between mapping and execution steps)
|
||||
const [sieData, setSieData] = useState<SIEData | null>(null)
|
||||
const companyAccountsForVatRef = useRef<BASAccount[]>([])
|
||||
|
||||
// Options state
|
||||
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
|
||||
@@ -2539,16 +2553,29 @@ export default function ArcimMigrationWorkspace({
|
||||
throw apiError(data, `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSieData(data)
|
||||
const data = await res.json() as SIEData
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
const accountsBody = await accountsRes.json().catch(() => ({})) as {
|
||||
data?: BASAccount[]
|
||||
error?: unknown
|
||||
}
|
||||
if (!accountsRes.ok) {
|
||||
throw apiError(accountsBody, `HTTP ${accountsRes.status}`)
|
||||
}
|
||||
companyAccountsForVatRef.current = accountsBody.data ?? []
|
||||
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, accountsBody.data ?? [])
|
||||
setSieData({ ...data, mappings: enrichedMappings })
|
||||
|
||||
// If all SIE files are already imported, disable SIE import by default
|
||||
if (data.allImported) {
|
||||
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
|
||||
}
|
||||
|
||||
// Auto-skip mapping step if all accounts are mapped or all files already imported
|
||||
if (data.mappingStats.unmapped === 0 || data.allImported) {
|
||||
const needsVatReview = enrichedMappings.some(mapping =>
|
||||
mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed
|
||||
)
|
||||
// Auto-skip only when there is neither account mapping nor VAT review work.
|
||||
if ((data.mappingStats.unmapped === 0 && !needsVatReview) || data.allImported) {
|
||||
setStep('options')
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -2571,10 +2598,14 @@ export default function ArcimMigrationWorkspace({
|
||||
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
|
||||
if (!sieData) return
|
||||
|
||||
const updatedMappings = sieData.mappings.map(m =>
|
||||
m.sourceAccount === sourceAccount
|
||||
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
|
||||
: m
|
||||
const updatedMappings = enrichChangedAccountMappingWithVat(
|
||||
sieData.mappings.map(m =>
|
||||
m.sourceAccount === sourceAccount
|
||||
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
|
||||
: m
|
||||
),
|
||||
sourceAccount,
|
||||
companyAccountsForVatRef.current,
|
||||
)
|
||||
setSieData(prev => prev ? {
|
||||
...prev,
|
||||
@@ -2587,6 +2618,21 @@ export default function ArcimMigrationWorkspace({
|
||||
} : null)
|
||||
}, [sieData])
|
||||
|
||||
const handleVatTreatmentChange = useCallback((
|
||||
sourceAccount: string,
|
||||
treatment: AccountVatTreatment | null,
|
||||
rate: number | null,
|
||||
) => {
|
||||
setSieData(prev => prev ? {
|
||||
...prev,
|
||||
mappings: applyVatTreatmentReview(prev.mappings, sourceAccount, treatment, rate),
|
||||
} : null)
|
||||
}, [])
|
||||
|
||||
const handleMappingContinue = useCallback(() => {
|
||||
setStep('options')
|
||||
}, [])
|
||||
|
||||
const handleStartMigration = useCallback(async () => {
|
||||
if (!consentId) return
|
||||
|
||||
@@ -2832,7 +2878,8 @@ export default function ArcimMigrationWorkspace({
|
||||
error={error}
|
||||
errorDetails={errorDetails}
|
||||
onMappingChange={handleMappingChange}
|
||||
onContinue={() => setStep('options')}
|
||||
onVatTreatmentChange={handleVatTreatmentChange}
|
||||
onContinue={handleMappingContinue}
|
||||
onBack={() => setStep('preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ interface AccountMappingStepProps {
|
||||
mappings: AccountMapping[]
|
||||
basAccounts: BASAccount[]
|
||||
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
|
||||
onVatTreatmentChange?: (
|
||||
onVatTreatmentChange: (
|
||||
sourceAccount: string,
|
||||
treatment: AccountVatTreatment | null,
|
||||
rate: number | null,
|
||||
@@ -238,8 +238,8 @@ export default function AccountMappingStep({
|
||||
<TableHead className="w-36">Källkonto</TableHead>
|
||||
<TableHead>Källnamn</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
|
||||
<TableHead className="w-64">Målkonto</TableHead>
|
||||
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
|
||||
<TableHead className="w-24">Konfidens</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -255,8 +255,43 @@ export default function AccountMappingStep({
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{onVatTreatmentChange &&
|
||||
mapping.sourceAccount === mapping.targetAccount &&
|
||||
<Select
|
||||
value={mapping.targetAccount || 'none'}
|
||||
onValueChange={(value) => {
|
||||
const account = basAccounts.find((a) => a.account_number === value)
|
||||
onMappingChange(
|
||||
mapping.sourceAccount,
|
||||
value === 'none' ? '' : value,
|
||||
account?.account_name || ''
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="Välj konto..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
<SelectItem value="none">-- Välj konto --</SelectItem>
|
||||
{Object.entries(accountsByClass).map(([className, accounts]) => (
|
||||
<div key={className}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
|
||||
{className}
|
||||
</div>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem
|
||||
key={account.account_number}
|
||||
value={account.account_number}
|
||||
>
|
||||
<span className="font-mono mr-2">{account.account_number}</span>
|
||||
{account.account_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{mapping.sourceAccount === mapping.targetAccount &&
|
||||
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
|
||||
<div className="flex min-w-72 gap-2">
|
||||
<Select
|
||||
@@ -267,8 +302,10 @@ export default function AccountMappingStep({
|
||||
: value as AccountVatTreatment
|
||||
const accountClass = Number(mapping.sourceAccount.charAt(0))
|
||||
const rate = treatment
|
||||
? defaultRateForVatTreatment(treatment, accountClass)
|
||||
: null
|
||||
? mapping.defaultVatRate == null || mapping.vatTreatmentSuggested
|
||||
? defaultRateForVatTreatment(treatment, accountClass)
|
||||
: mapping.defaultVatRate
|
||||
: mapping.defaultVatRate ?? null
|
||||
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
|
||||
}}
|
||||
>
|
||||
@@ -278,7 +315,7 @@ export default function AccountMappingStep({
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
|
||||
{vatTreatmentsForAccountClass(
|
||||
Number(mapping.sourceAccount.charAt(0)),
|
||||
Number(mapping.sourceAccount.charAt(0))
|
||||
).map((treatment) => (
|
||||
<SelectItem key={treatment} value={treatment}>
|
||||
{t(`vat_treatment_${treatment}`)}
|
||||
@@ -326,42 +363,6 @@ export default function AccountMappingStep({
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={mapping.targetAccount || 'none'}
|
||||
onValueChange={(value) => {
|
||||
const account = basAccounts.find((a) => a.account_number === value)
|
||||
onMappingChange(
|
||||
mapping.sourceAccount,
|
||||
value === 'none' ? '' : value,
|
||||
account?.account_name || ''
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="Välj konto..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
<SelectItem value="none">-- Välj konto --</SelectItem>
|
||||
{Object.entries(accountsByClass).map(([className, accounts]) => (
|
||||
<div key={className}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
|
||||
{className}
|
||||
</div>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem
|
||||
key={account.account_number}
|
||||
value={account.account_number}
|
||||
>
|
||||
<span className="font-mono mr-2">{account.account_number}</span>
|
||||
{account.account_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{mapping.targetAccount && (
|
||||
<ConfidenceBadge
|
||||
@@ -422,7 +423,11 @@ export default function AccountMappingStep({
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
|
||||
{canContinue ? 'Fortsätt till granskning' : `${stats.unmapped} konton saknar mappning`}
|
||||
{canContinue
|
||||
? 'Fortsätt till granskning'
|
||||
: stats.unmapped > 0
|
||||
? `${stats.unmapped} konton saknar mappning`
|
||||
: `${stats.vatReview} momskoder återstår att bekräfta`}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -392,4 +392,38 @@ describe('gnubok_update_account', () => {
|
||||
expect(result.preview.current.account_name).toBe('Förbrukningsinventarier')
|
||||
expect(result.preview.changes).toEqual({ account_name: 'Verktyg', is_active: false })
|
||||
})
|
||||
|
||||
it('preserves an existing booking rate when only treatment changes', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
account_number: '4056', account_name: 'EU-varor', default_vat_rate: 0.12,
|
||||
default_vat_treatment: null, is_active: true,
|
||||
},
|
||||
})
|
||||
const result = (await updateAccount.execute(
|
||||
{ account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods', dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: { changes: Record<string, unknown> } }
|
||||
|
||||
expect(result.preview.changes).toEqual({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
})
|
||||
})
|
||||
|
||||
it('can clear a treatment to restore BAS fallback', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
account_number: '3041', account_name: 'Försäljning', default_vat_rate: 0.25,
|
||||
default_vat_treatment: 'standard_25', is_active: true,
|
||||
},
|
||||
})
|
||||
const result = (await updateAccount.execute(
|
||||
{ account_number: '3041', default_vat_treatment: null, dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: { changes: Record<string, unknown> } }
|
||||
|
||||
expect(result.preview.changes).toEqual({ default_vat_treatment: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,9 +168,12 @@ describe('tools/list payload size guard', () => {
|
||||
// Both property descriptions trimmed to one sentence first; headroom
|
||||
// before the change was under 20 tokens, so even the trimmed wire
|
||||
// contract crossed.
|
||||
// * 59.5K to 59.7K with account VAT treatments: create_account and
|
||||
// update_account both expose the 12-value treatment vocabulary. The
|
||||
// descriptions are minimal; the enum values are the wire contract.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(59_500)
|
||||
expect(approxTokens).toBeLessThan(59_700)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,9 @@ interface MockLine {
|
||||
interface MockChartAccount {
|
||||
account_number: string
|
||||
account_name: string
|
||||
account_class: number
|
||||
default_vat_rate: number | null
|
||||
default_vat_treatment: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +129,9 @@ describe('gnubok_vat_close_check: declaration completeness', () => {
|
||||
[{
|
||||
account_number: '3011',
|
||||
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
|
||||
account_class: 3,
|
||||
default_vat_rate: null,
|
||||
default_vat_treatment: null,
|
||||
}],
|
||||
),
|
||||
)
|
||||
@@ -139,6 +143,54 @@ describe('gnubok_vat_close_check: declaration completeness', () => {
|
||||
expect(result.ready_to_close).toBe(true)
|
||||
})
|
||||
|
||||
it('honors an explicit override of a standard BAS revenue account', async () => {
|
||||
const result = await computeVatCloseCheck(
|
||||
PERIOD,
|
||||
'company-1',
|
||||
mockSupabase(
|
||||
[
|
||||
{ entry: 'e1', account_number: '3011', credit_amount: 1000 },
|
||||
{ entry: 'e1', account_number: '1510', debit_amount: 1000 },
|
||||
],
|
||||
[{
|
||||
account_number: '3011',
|
||||
account_name: 'Momsfri försäljning',
|
||||
account_class: 3,
|
||||
default_vat_rate: 0,
|
||||
default_vat_treatment: 'exempt',
|
||||
}],
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a custom EU goods basis account in the MCP close check', async () => {
|
||||
const result = await computeVatCloseCheck(
|
||||
PERIOD,
|
||||
'company-1',
|
||||
mockSupabase(
|
||||
[
|
||||
{ entry: 'e1', account_number: '4056', debit_amount: 1000 },
|
||||
{ entry: 'e1', account_number: '2440', credit_amount: 1000 },
|
||||
{ entry: 'e1', account_number: '2614', credit_amount: 250 },
|
||||
{ entry: 'e1', account_number: '2645', debit_amount: 250 },
|
||||
],
|
||||
[{
|
||||
account_number: '4056',
|
||||
account_name: 'Inköp varor EU 25 %',
|
||||
account_class: 4,
|
||||
default_vat_rate: 0.25,
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
}],
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.declaration_checks.map((finding) => finding.code))
|
||||
.not.toContain('RC_BASIS_MISSING')
|
||||
expect(result.ready_to_close).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses the #1164 declaration: fiktiv moms on 2614/2645 with no basbelopp on 44xx/45xx', async () => {
|
||||
// Both VAT legs of a reverse-charge purchase booked, but the cost went
|
||||
// straight to 6540 instead of the 4535 basis account, so rutor 20-24 stay
|
||||
|
||||
+23
-3
@@ -17,6 +17,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
const { mockFindRcBasisGaps } = vi.hoisted(() => ({
|
||||
mockFindRcBasisGaps: vi.fn(),
|
||||
}))
|
||||
const mockSkvRequest = vi.fn()
|
||||
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
@@ -47,10 +50,8 @@ vi.mock('@/lib/reports/vat-declaration', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// The per-verifikat FK004 scan has its own coverage in lib/reports/__tests__;
|
||||
// here it must simply not add findings of its own.
|
||||
vi.mock('@/lib/reports/rc-basis-gaps', () => ({
|
||||
findRcBasisGaps: vi.fn(async () => []),
|
||||
findRcBasisGaps: (...args: unknown[]) => mockFindRcBasisGaps(...args),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
@@ -87,10 +88,12 @@ function makeRutor(partial: Partial<VatDeclarationRutor> = {}): VatDeclarationRu
|
||||
function setDeclaration(
|
||||
rutor: VatDeclarationRutor,
|
||||
rcInput?: Record<string, { debit: number; credit: number }>,
|
||||
rcBasisByRate?: { r25: number; r12: number; r6: number },
|
||||
) {
|
||||
mockCalculateVatDeclaration.mockResolvedValue({
|
||||
rutor,
|
||||
...(rcInput ? { rcInputAccountTotals: rcInput } : {}),
|
||||
...(rcBasisByRate ? { rcBasisByRate } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +119,7 @@ function skvOk(body: unknown = { kontrollResultat: { status: 'OK', resultat: []
|
||||
let prevEnv: string | undefined
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFindRcBasisGaps.mockResolvedValue([])
|
||||
prevEnv = process.env.SKATTEVERKET_ENABLED
|
||||
process.env.SKATTEVERKET_ENABLED = 'true'
|
||||
})
|
||||
@@ -215,6 +219,22 @@ describe('gnubok_vat_declaration_validate', () => {
|
||||
expect(result.completeness_ok).toBe(true)
|
||||
})
|
||||
|
||||
it('uses declaration rate evidence when a correction gap is covered in aggregate', async () => {
|
||||
mockFindRcBasisGaps.mockResolvedValue([{}])
|
||||
setDeclaration(
|
||||
makeRutor({ ruta20: 5000, ruta30: 1250, ruta48: 1250 }),
|
||||
rcInput({ '2645': 1250 }),
|
||||
{ r25: 5000, r12: 0, r6: 0 },
|
||||
)
|
||||
skvOk()
|
||||
|
||||
const result = await run()
|
||||
|
||||
expect(result.completeness_checks.find((c) => c.code === 'RC_BASIS_MISSING')?.status)
|
||||
.toBe('WARNING')
|
||||
expect(result.completeness_ok).toBe(true)
|
||||
})
|
||||
|
||||
it('arithmetic_ok is false when Skatteverket returns an ERROR, independently of completeness', async () => {
|
||||
setDeclaration(CLEAN)
|
||||
skvOk({
|
||||
|
||||
@@ -9,6 +9,8 @@ interface AccountSummary {
|
||||
normal_balance: string
|
||||
is_active: boolean
|
||||
default_vat_code: string | null
|
||||
default_vat_rate: number | null
|
||||
default_vat_treatment: string | null
|
||||
}
|
||||
|
||||
export const chartOfAccountsResource: McpResource = {
|
||||
@@ -25,7 +27,7 @@ export const chartOfAccountsResource: McpResource = {
|
||||
accounts = await fetchAllRows<AccountSummary>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code')
|
||||
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code, default_vat_rate, default_vat_treatment')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to)
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
rcInputTotalsFromDeclaration,
|
||||
calculateVatDeclaration,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
|
||||
import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts'
|
||||
// The momsdeklaration completeness checks live in core (lib/reports) and are
|
||||
// shared with the web UI's "Kontroll av underlaget" gate. The MCP surface
|
||||
// imports them instead of mirroring them: a hand-rolled copy here is exactly
|
||||
@@ -88,6 +88,12 @@ import {
|
||||
} from './tool-namespace'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import {
|
||||
ACCOUNT_VAT_TREATMENTS,
|
||||
defaultRateForVatTreatment,
|
||||
isAccountVatTreatment,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/account'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
@@ -1359,33 +1365,15 @@ const SKV_AGI_STATUS_OUTPUT_SCHEMA = {
|
||||
// (the original sale's VAT silently disappears) and over-credit Period N+M
|
||||
// (a reversal with no original), incorrect per ML 2023:200.
|
||||
|
||||
/** Common BAS taxable-revenue accounts that contribute to ruta 05.
|
||||
*
|
||||
* Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri,
|
||||
* exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the
|
||||
* rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the
|
||||
* buyer's VAT number is invalid).
|
||||
*
|
||||
* This hand-maintained widening predates #1261 and is kept so no company
|
||||
* loses a figure it already saw. It is no longer the only path: a company's
|
||||
* own class 3 konto marked with a moms-sats is resolved at runtime by
|
||||
* fetchDynamicRuta05Accounts and unioned in below, which is what actually
|
||||
* covers non-standard charts (Accounted's BAS chart ships no varugrupp
|
||||
* accounts at all). */
|
||||
const RUTA_05_ACCOUNTS = [
|
||||
// The 30xx gruppkonto. ACCOUNT_RUTA maps it to ruta05, so leaving it out here
|
||||
// made a balance on 3000 appear in the filed projection but not in
|
||||
// report.rutor.ruta05.
|
||||
// Keep the MCP report's historical ruta 05 widening for accounts that do not
|
||||
// have an explicit treatment. The effective resolver adds custom ruta 05
|
||||
// accounts and removes any of these when the company overrides their treatment.
|
||||
const RUTA_05_COMPATIBILITY_ACCOUNTS = [
|
||||
'3000',
|
||||
// Domestic sales by VAT rate (canonical BAS)
|
||||
'3001', '3002', '3003', '3005', '3006', '3007', '3008',
|
||||
// Taxable EU goods (momspliktig, buyer's VAT number invalid or buyer is private)
|
||||
'3106',
|
||||
// Domestic services (alternative numbering some companies use)
|
||||
'3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048',
|
||||
// Domestic goods (alternative numbering)
|
||||
'3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058',
|
||||
// Other domestic taxable
|
||||
'3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078',
|
||||
] as const
|
||||
|
||||
@@ -1408,6 +1396,7 @@ export interface VatReportResult {
|
||||
|
||||
export interface VatReportWithRutor {
|
||||
report: VatReportResult
|
||||
dynamicVatAccounts: Awaited<ReturnType<typeof fetchDynamicVatAccounts>>
|
||||
/**
|
||||
* The FULL SKV 4700 projection of the same ledger aggregate, via core's
|
||||
* `rutorFromTotals`. `report.rutor` is the trimmed agent-facing view: it has
|
||||
@@ -1546,43 +1535,36 @@ export async function computeVatReportWithRutor(
|
||||
accountTotals.set(acc, existing)
|
||||
}
|
||||
|
||||
function creditBalance(acc: string): number {
|
||||
const t = accountTotals.get(acc)
|
||||
return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0
|
||||
}
|
||||
|
||||
function debitBalance(acc: string): number {
|
||||
const t = accountTotals.get(acc)
|
||||
return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0
|
||||
}
|
||||
|
||||
// The company's own momspliktiga intäktskonton join the hand-maintained list.
|
||||
// Deduped: an account can appear in both (e.g. 3041 with a moms-sats set),
|
||||
// and counting it twice would inflate ruta 05.
|
||||
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
|
||||
const ruta05Accounts = [...new Set([...RUTA_05_ACCOUNTS, ...dynamicRuta05.accounts])]
|
||||
const ruta05 = ruta05Accounts.reduce((sum, acc) => sum + creditBalance(acc), 0)
|
||||
const ruta10 = creditBalance('2611')
|
||||
const ruta11 = creditBalance('2621')
|
||||
const ruta12 = creditBalance('2631')
|
||||
const ruta30 = creditBalance('2614')
|
||||
const ruta31 = creditBalance('2624')
|
||||
const ruta32 = creditBalance('2634')
|
||||
const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU)
|
||||
const ruta39 = creditBalance('3308')
|
||||
const ruta40 = creditBalance('3305')
|
||||
function creditBalance(acc: string): number {
|
||||
return -debitBalance(acc)
|
||||
}
|
||||
|
||||
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
|
||||
const declarationRutor = rutorFromTotals(accountTotals, dynamicVatAccounts)
|
||||
const {
|
||||
ruta10, ruta11, ruta12, ruta30, ruta31, ruta32,
|
||||
ruta35, ruta39, ruta40, ruta48, ruta49, ruta60, ruta61, ruta62,
|
||||
} = declarationRutor
|
||||
const reportRuta05Accounts = new Set<string>(
|
||||
RUTA_05_COMPATIBILITY_ACCOUNTS.filter(
|
||||
(account) => !dynamicVatAccounts.explicitAccounts.has(account),
|
||||
),
|
||||
)
|
||||
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
|
||||
if (mapping.box === 'ruta05') reportRuta05Accounts.add(account)
|
||||
}
|
||||
const ruta05 = [...reportRuta05Accounts]
|
||||
.reduce((sum, account) => sum + creditBalance(account), 0)
|
||||
// Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the
|
||||
// importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the
|
||||
// matching deductible input to 2645 (rolls into ruta 48 below).
|
||||
const ruta60 = creditBalance('2615')
|
||||
const ruta61 = creditBalance('2625')
|
||||
const ruta62 = creditBalance('2635')
|
||||
const calculatedInput2645 = debitBalance('2645')
|
||||
const calculatedInput2647 = debitBalance('2647')
|
||||
const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647
|
||||
const ruta49 = Math.round(
|
||||
(ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100
|
||||
) / 100
|
||||
|
||||
const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December']
|
||||
@@ -1642,7 +1624,8 @@ export async function computeVatReportWithRutor(
|
||||
// (incl. rutor 20-24 and 50) instead of the trimmed report view.
|
||||
return {
|
||||
report,
|
||||
declarationRutor: rutorFromTotals(accountTotals, dynamicRuta05.accounts),
|
||||
declarationRutor,
|
||||
dynamicVatAccounts,
|
||||
accountTotals,
|
||||
}
|
||||
}
|
||||
@@ -1676,6 +1659,8 @@ async function runVatCompletenessChecks(
|
||||
year: number,
|
||||
period: number,
|
||||
accountTotals?: VatCheckAccountTotals,
|
||||
dynamicVatAccounts?: Awaited<ReturnType<typeof fetchDynamicVatAccounts>>,
|
||||
rcBasisByRate?: { r25: number; r12: number; r6: number },
|
||||
): Promise<VatDeclarationCheck[]> {
|
||||
let scan: RcBasisGapScan
|
||||
try {
|
||||
@@ -1688,7 +1673,10 @@ async function runVatCompletenessChecks(
|
||||
// caller supplied the account totals; without them the per-voucher gaps
|
||||
// keep their blocking ERROR tier rather than guessing.
|
||||
const evidence = accountTotals
|
||||
? { rutor, rcBasisByRate: rcBasisTotalsByRate(accountTotals) }
|
||||
? {
|
||||
rutor,
|
||||
rcBasisByRate: rcBasisByRate ?? rcBasisTotalsByRate(accountTotals, dynamicVatAccounts),
|
||||
}
|
||||
: undefined
|
||||
return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan, evidence)
|
||||
}
|
||||
@@ -2073,7 +2061,7 @@ export async function computeVatCloseCheck(
|
||||
// step 4b: they need rutor 20-24 and 50, which the report view omits, plus
|
||||
// the per-account totals so the RC input comparison reads 2645/2647
|
||||
// instead of the ruta 48 aggregate.
|
||||
const { report: vatReport, declarationRutor, accountTotals } =
|
||||
const { report: vatReport, declarationRutor, dynamicVatAccounts, accountTotals } =
|
||||
await computeVatReportWithRutor(args, companyId, supabase)
|
||||
const { start, end, type: periodType, year, period } = vatReport.period
|
||||
|
||||
@@ -2184,6 +2172,7 @@ export async function computeVatCloseCheck(
|
||||
Number(year),
|
||||
Number(period),
|
||||
accountTotals,
|
||||
dynamicVatAccounts,
|
||||
)
|
||||
|
||||
// Zero deductible input VAT against self-assessed utgående moms is
|
||||
@@ -6409,6 +6398,11 @@ export const tools: McpTool[] = [
|
||||
description: { type: 'string' },
|
||||
default_vat_code: { type: 'string' },
|
||||
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
|
||||
default_vat_treatment: {
|
||||
type: 'string',
|
||||
enum: [...ACCOUNT_VAT_TREATMENTS],
|
||||
description: 'VAT return treatment.',
|
||||
},
|
||||
sru_code: { type: 'string', description: 'Prefilled for BAS numbers.' },
|
||||
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
|
||||
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
|
||||
@@ -6470,6 +6464,17 @@ export const tools: McpTool[] = [
|
||||
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
|
||||
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
|
||||
}
|
||||
const vatTreatment = args.default_vat_treatment
|
||||
if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) {
|
||||
throw new Error('default_vat_treatment is not supported')
|
||||
}
|
||||
const accountClass = Number(accountNumber[0])
|
||||
if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) {
|
||||
throw new Error('default_vat_treatment is not valid for this account class')
|
||||
}
|
||||
const effectiveVatRate = vatTreatment && vatRate === undefined
|
||||
? defaultRateForVatTreatment(vatTreatment, accountClass)
|
||||
: vatRate
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
account_number: accountNumber,
|
||||
@@ -6479,7 +6484,8 @@ export const tools: McpTool[] = [
|
||||
plan_type: ref ? 'full_bas' : 'k1',
|
||||
description: String(args.description ?? '').trim() || ref?.description || undefined,
|
||||
default_vat_code: String(args.default_vat_code ?? '').trim() || undefined,
|
||||
default_vat_rate: vatRate,
|
||||
default_vat_rate: effectiveVatRate,
|
||||
default_vat_treatment: vatTreatment,
|
||||
sru_code: String(args.sru_code ?? '').trim() || ref?.sru_code || undefined,
|
||||
}
|
||||
|
||||
@@ -6514,6 +6520,11 @@ export const tools: McpTool[] = [
|
||||
description: { type: 'string' },
|
||||
default_vat_code: { type: 'string' },
|
||||
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Default VAT rate as a fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
|
||||
default_vat_treatment: {
|
||||
type: ['string', 'null'],
|
||||
enum: [...ACCOUNT_VAT_TREATMENTS, null],
|
||||
description: 'VAT return treatment.',
|
||||
},
|
||||
sru_code: { type: 'string' },
|
||||
is_active: { type: 'boolean', description: 'false deactivates (hides from pickers, keeps history); true (re)activates.' },
|
||||
dry_run: { type: 'boolean' },
|
||||
@@ -6535,7 +6546,7 @@ export const tools: McpTool[] = [
|
||||
|
||||
const { data: current, error: fetchErr } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, description, default_vat_code, default_vat_rate, sru_code, is_active')
|
||||
.select('account_number, account_name, description, default_vat_code, default_vat_rate, default_vat_treatment, sru_code, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', accountNumber)
|
||||
.maybeSingle()
|
||||
@@ -6548,17 +6559,30 @@ export const tools: McpTool[] = [
|
||||
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
|
||||
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
|
||||
}
|
||||
const vatTreatment = args.default_vat_treatment
|
||||
if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) {
|
||||
throw new Error('default_vat_treatment is not supported')
|
||||
}
|
||||
const accountClass = Number(accountNumber[0])
|
||||
if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) {
|
||||
throw new Error('default_vat_treatment is not valid for this account class')
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = { account_number: accountNumber }
|
||||
const changes: Record<string, unknown> = {}
|
||||
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'sru_code', 'is_active']) {
|
||||
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'default_vat_treatment', 'sru_code', 'is_active']) {
|
||||
if (args[key] !== undefined) {
|
||||
params[key] = args[key]
|
||||
changes[key] = args[key]
|
||||
}
|
||||
}
|
||||
if (vatTreatment && vatRate === undefined && current.default_vat_rate == null) {
|
||||
const derivedRate = defaultRateForVatTreatment(vatTreatment, accountClass)
|
||||
params.default_vat_rate = derivedRate
|
||||
changes.default_vat_rate = derivedRate
|
||||
}
|
||||
if (Object.keys(changes).length === 0) {
|
||||
throw new Error('Nothing to update: pass at least one of account_name, description, default_vat_code, default_vat_rate, sru_code, is_active.')
|
||||
throw new Error('Nothing to update: pass at least one account field.')
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'update_account',
|
||||
@@ -11221,6 +11245,8 @@ export const tools: McpTool[] = [
|
||||
const completenessChecks = await runVatCompletenessChecks(
|
||||
supabase, companyId, declaration.rutor, periodType, year, period,
|
||||
rcInputTotalsFromDeclaration(declaration),
|
||||
undefined,
|
||||
declaration.rcBasisByRate,
|
||||
)
|
||||
const completenessOk = !isFilingBlocked(completenessChecks)
|
||||
|
||||
|
||||
@@ -1996,23 +1996,6 @@ describe('CreateAccountSchema', () => {
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a VAT treatment that does not apply to the account class', () => {
|
||||
expect(CreateAccountSchema.safeParse({
|
||||
account_number: '5010',
|
||||
account_name: 'Consulting costs',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'standard_25',
|
||||
}).success).toBe(false)
|
||||
expect(CreateAccountSchema.safeParse({
|
||||
account_number: '5010',
|
||||
account_name: 'EU services',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
default_vat_treatment: 'reverse_charge_eu_services',
|
||||
}).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
@@ -2327,6 +2310,9 @@ describe('UpdateAccountSchema', () => {
|
||||
expect(UpdateAccountSchema.safeParse({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
}).success).toBe(true)
|
||||
expect(UpdateAccountSchema.safeParse({
|
||||
default_vat_treatment: 'reverse_charge_non_eu_services',
|
||||
}).success).toBe(true)
|
||||
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: null }).success).toBe(true)
|
||||
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: 'eu_purchase' }).success).toBe(false)
|
||||
})
|
||||
|
||||
+2
-17
@@ -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({
|
||||
|
||||
@@ -121,6 +121,22 @@ describe('syncMappedAccounts: create pass', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the booking rate for a confirmed treatment when the rate is unset', async () => {
|
||||
const { supabase, inserts } = buildCapturingSupabase()
|
||||
const result = await run(supabase, [
|
||||
mapping({
|
||||
sourceAccount: '4056',
|
||||
targetAccount: '4056',
|
||||
sourceName: 'Inköp varor EU',
|
||||
defaultVatTreatment: 'reverse_charge_eu_goods',
|
||||
defaultVatRate: null,
|
||||
vatTreatmentReviewed: true,
|
||||
}),
|
||||
])
|
||||
expect(result.error).toBeNull()
|
||||
expect(inserts[0].default_vat_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
it('creates a missing BAS account with the BAS default name when the file has no custom name', async () => {
|
||||
const { supabase, inserts } = buildCapturingSupabase()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyVatTreatmentReview,
|
||||
enrichChangedAccountMappingWithVat,
|
||||
enrichAccountMappingsWithVat,
|
||||
} from '../account-vat-treatment'
|
||||
import type { AccountMapping } from '../types'
|
||||
@@ -32,28 +33,6 @@ describe('enrichAccountMappingsWithVat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires review for suggested class 5 and 6 purchase treatments', () => {
|
||||
const results = enrichAccountMappingsWithVat(
|
||||
[
|
||||
mapping('5010', 'Inköp tjänst EU'),
|
||||
mapping('6010', 'Inköp tjänst utanför EU'),
|
||||
],
|
||||
[],
|
||||
)
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
defaultVatTreatment: 'reverse_charge_eu_services',
|
||||
requiresVatTreatmentReview: true,
|
||||
vatTreatmentReviewed: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
defaultVatTreatment: 'export_services',
|
||||
requiresVatTreatmentReview: true,
|
||||
vatTreatmentReviewed: false,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an existing account treatment without asking again', () => {
|
||||
const [result] = enrichAccountMappingsWithVat(
|
||||
[mapping('3041', 'Försäljning tjänst 25% sv')],
|
||||
@@ -70,25 +49,99 @@ describe('enrichAccountMappingsWithVat', () => {
|
||||
requiresVatTreatmentReview: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an existing booking rate when suggesting a missing treatment', () => {
|
||||
const [result] = enrichAccountMappingsWithVat(
|
||||
[mapping('3041', 'Försäljning tjänst 25% sv')],
|
||||
[{
|
||||
account_number: '3041',
|
||||
default_vat_treatment: null,
|
||||
default_vat_rate: 0.12,
|
||||
} as never],
|
||||
)
|
||||
expect(result.defaultVatTreatment).toBe('standard_25')
|
||||
expect(result.defaultVatRate).toBe(0.12)
|
||||
})
|
||||
|
||||
it('requires review for a suggested class 6 service treatment', () => {
|
||||
const [result] = enrichAccountMappingsWithVat(
|
||||
[mapping('6545', 'Inköp tjänster utanför EU 25%')],
|
||||
[],
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_non_eu_services',
|
||||
requiresVatTreatmentReview: true,
|
||||
vatTreatmentReviewed: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVatTreatmentReview', () => {
|
||||
it('marks only the selected row reviewed, including class 5 and 6 accounts', () => {
|
||||
const mappings = [
|
||||
mapping('5010', 'Inköp tjänst EU'),
|
||||
mapping('3041', 'Försäljning tjänst 25% sv'),
|
||||
]
|
||||
const result = applyVatTreatmentReview(
|
||||
it('persists a suggestion only after an explicit row confirmation', () => {
|
||||
const mappings = enrichAccountMappingsWithVat([
|
||||
mapping('4056', 'Inköp varor 25% EU'),
|
||||
], [])
|
||||
|
||||
expect(mappings[0].vatTreatmentReviewed).toBe(false)
|
||||
const reviewed = applyVatTreatmentReview(
|
||||
mappings,
|
||||
'5010',
|
||||
'reverse_charge_eu_services',
|
||||
0.25,
|
||||
'4056',
|
||||
mappings[0].defaultVatTreatment ?? null,
|
||||
mappings[0].defaultVatRate ?? null,
|
||||
)
|
||||
expect(result[0]).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_eu_services',
|
||||
defaultVatRate: 0.25,
|
||||
expect(reviewed[0]).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_eu_goods',
|
||||
vatTreatmentReviewed: true,
|
||||
vatTreatmentSuggested: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears hidden review state on remap and restores it on identity mapping', () => {
|
||||
const [suggested] = enrichAccountMappingsWithVat([
|
||||
mapping('4056', 'Inköp varor 25% EU'),
|
||||
], [])
|
||||
|
||||
const [remapped] = enrichAccountMappingsWithVat([{
|
||||
...suggested,
|
||||
targetAccount: '4010',
|
||||
targetName: 'Inköp material',
|
||||
}], [])
|
||||
expect(remapped).toMatchObject({
|
||||
defaultVatTreatment: null,
|
||||
requiresVatTreatmentReview: false,
|
||||
vatTreatmentReviewed: true,
|
||||
})
|
||||
|
||||
const [identity] = enrichAccountMappingsWithVat([{
|
||||
...remapped,
|
||||
targetAccount: '4056',
|
||||
targetName: 'Inköp varor 25% EU',
|
||||
}], [])
|
||||
expect(identity).toMatchObject({
|
||||
defaultVatTreatment: 'reverse_charge_eu_goods',
|
||||
requiresVatTreatmentReview: true,
|
||||
vatTreatmentReviewed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves another row review when one mapping changes', () => {
|
||||
const initial = enrichAccountMappingsWithVat([
|
||||
mapping('3041', 'Försäljning tjänst 25% sv'),
|
||||
mapping('4056', 'Inköp varor 25% EU'),
|
||||
], [])
|
||||
const reviewed = applyVatTreatmentReview(initial, '3041', 'exempt', 0)
|
||||
const remapped = enrichChangedAccountMappingWithVat(
|
||||
reviewed.map((item) => item.sourceAccount === '4056'
|
||||
? { ...item, targetAccount: '4010', targetName: 'Inköp material' }
|
||||
: item),
|
||||
'4056',
|
||||
[],
|
||||
)
|
||||
|
||||
expect(remapped[0]).toMatchObject({
|
||||
defaultVatTreatment: 'exempt',
|
||||
defaultVatRate: 0,
|
||||
vatTreatmentReviewed: true,
|
||||
})
|
||||
expect(result[1].vatTreatmentReviewed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,16 @@ export function enrichAccountMappingsWithVat(
|
||||
)
|
||||
|
||||
return mappings.map((mapping) => {
|
||||
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
|
||||
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) {
|
||||
return {
|
||||
...mapping,
|
||||
defaultVatTreatment: null,
|
||||
defaultVatRate: null,
|
||||
vatTreatmentSuggested: false,
|
||||
vatTreatmentReviewed: true,
|
||||
requiresVatTreatmentReview: false,
|
||||
}
|
||||
}
|
||||
const accountClass = Number(mapping.sourceAccount.charAt(0))
|
||||
if (accountClass < 3 || accountClass > 6) return mapping
|
||||
|
||||
@@ -39,10 +48,10 @@ export function enrichAccountMappingsWithVat(
|
||||
return {
|
||||
...mapping,
|
||||
defaultVatTreatment: suggestion?.treatment ?? null,
|
||||
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
|
||||
defaultVatRate: existing?.default_vat_rate ?? suggestion?.rate ?? null,
|
||||
vatTreatmentSuggested: Boolean(suggestion),
|
||||
vatTreatmentReviewed: false,
|
||||
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
|
||||
requiresVatTreatmentReview: accountClass === 3 || accountClass === 4 || Boolean(suggestion),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,3 +74,15 @@ export function applyVatTreatmentReview(
|
||||
: mapping
|
||||
)
|
||||
}
|
||||
|
||||
export function enrichChangedAccountMappingWithVat(
|
||||
mappings: AccountMapping[],
|
||||
sourceAccount: string,
|
||||
existingAccounts: BASAccount[],
|
||||
): AccountMapping[] {
|
||||
return mappings.map((mapping) =>
|
||||
mapping.sourceAccount === sourceAccount
|
||||
? enrichAccountMappingsWithVat([mapping], existingAccounts)[0]
|
||||
: mapping
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,6 +189,51 @@ describe('commitPendingOperation: update_account', () => {
|
||||
expect(result.status).toBe('committed')
|
||||
})
|
||||
|
||||
it('derives an omitted booking rate at commit when the stored rate is unset', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' } }) // CAS claim
|
||||
enqueue({ data: { default_vat_rate: null } }) // current account
|
||||
enqueue({ data: { account_number: '4056', account_name: 'EU-varor', is_active: true } }) // update
|
||||
enqueue({ data: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'update_account',
|
||||
params: {
|
||||
account_number: '4056',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(findCalls('chart_of_accounts', 'update')[0]?.[0]).toMatchObject({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
default_vat_rate: 0.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a stored booking rate when a staged treatment omits it', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' } }) // CAS claim
|
||||
enqueue({ data: { default_vat_rate: 0.12 } }) // current account
|
||||
enqueue({ data: { account_number: '4056', account_name: 'EU-varor', is_active: true } }) // update
|
||||
enqueue({ data: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'update_account',
|
||||
params: {
|
||||
account_number: '4056',
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(findCalls('chart_of_accounts', 'update')[0]?.[0]).toEqual({
|
||||
default_vat_treatment: 'reverse_charge_eu_goods',
|
||||
})
|
||||
})
|
||||
|
||||
it('unknown account (PGRST116) auto-rejects with 404', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' } }) // CAS claim
|
||||
|
||||
@@ -128,6 +128,7 @@ import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pend
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
|
||||
import { defaultRateForVatTreatment } from '@/lib/vat/account-vat-treatment'
|
||||
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
|
||||
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
|
||||
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
|
||||
@@ -946,6 +947,13 @@ async function commitCreateAccount(
|
||||
// Same row shape as the dashboard create route
|
||||
// (app/api/bookkeeping/accounts/route.ts): class/group/sort_order derive
|
||||
// from the number so the two write paths cannot drift.
|
||||
const defaultVatRate = validated.default_vat_treatment && validated.default_vat_rate == null
|
||||
? defaultRateForVatTreatment(
|
||||
validated.default_vat_treatment,
|
||||
Number(validated.account_number[0]),
|
||||
)
|
||||
: validated.default_vat_rate ?? null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert({
|
||||
@@ -962,7 +970,8 @@ async function commitCreateAccount(
|
||||
is_system_account: false,
|
||||
description: validated.description ?? null,
|
||||
default_vat_code: validated.default_vat_code ?? null,
|
||||
default_vat_rate: validated.default_vat_rate ?? null,
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: validated.default_vat_treatment ?? null,
|
||||
sru_code: validated.sru_code ?? null,
|
||||
sort_order: parseInt(validated.account_number),
|
||||
})
|
||||
@@ -1001,6 +1010,31 @@ async function commitUpdateAccount(
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined) updateData[key] = value
|
||||
}
|
||||
|
||||
if (validated.default_vat_treatment && validated.default_vat_rate == null) {
|
||||
const { data: current, error: currentError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('default_vat_rate')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', account_number)
|
||||
.single()
|
||||
|
||||
if (currentError) {
|
||||
if (currentError.code === 'PGRST116') {
|
||||
return { error: 'Kontot hittades inte', status: 404 }
|
||||
}
|
||||
return { error: currentError.message, status: 500 }
|
||||
}
|
||||
|
||||
if (current.default_vat_rate == null) {
|
||||
updateData.default_vat_rate = defaultRateForVatTreatment(
|
||||
validated.default_vat_treatment,
|
||||
Number(account_number.charAt(0)),
|
||||
)
|
||||
} else {
|
||||
delete updateData.default_vat_rate
|
||||
}
|
||||
}
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return { error: 'Inget att uppdatera', status: 400 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
ACCOUNT_VAT_TREATMENTS,
|
||||
isVatTreatmentAllowedForAccountClass,
|
||||
} from '@/lib/vat/account-vat-treatment'
|
||||
|
||||
// Commit-boundary re-validation for staged chart-of-accounts operations
|
||||
// (gnubok_create_account / gnubok_update_account). A staged
|
||||
@@ -27,6 +31,8 @@ const defaultVatRate = z
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
const defaultVatTreatment = z.enum(ACCOUNT_VAT_TREATMENTS).nullable().optional()
|
||||
|
||||
/** Empty string / null → undefined, then bounded string. */
|
||||
const optString = (max: number) =>
|
||||
z.preprocess((v) => (v == null || v === '' ? undefined : v), z.string().max(max).optional())
|
||||
@@ -84,6 +90,7 @@ export const CreateAccountParamsSchema = z
|
||||
description: optString(2000),
|
||||
default_vat_code: optString(32),
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: defaultVatTreatment,
|
||||
sru_code: optString(16),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
@@ -91,6 +98,19 @@ export const CreateAccountParamsSchema = z
|
||||
if (conflict) {
|
||||
ctx.addIssue({ code: 'custom', message: conflict, path: ['account_type'] })
|
||||
}
|
||||
if (
|
||||
v.default_vat_treatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(
|
||||
v.default_vat_treatment,
|
||||
Number(v.account_number[0]),
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'VAT treatment is not valid for this account class',
|
||||
path: ['default_vat_treatment'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateAccountParamsSchema = z.object({
|
||||
@@ -99,8 +119,23 @@ export const UpdateAccountParamsSchema = z.object({
|
||||
description: clearableString(2000),
|
||||
default_vat_code: clearableString(32),
|
||||
default_vat_rate: defaultVatRate,
|
||||
default_vat_treatment: defaultVatTreatment,
|
||||
sru_code: clearableString(16),
|
||||
is_active: z.boolean().optional(),
|
||||
}).superRefine((v, ctx) => {
|
||||
if (
|
||||
v.default_vat_treatment &&
|
||||
!isVatTreatmentAllowedForAccountClass(
|
||||
v.default_vat_treatment,
|
||||
Number(v.account_number[0]),
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'VAT treatment is not valid for this account class',
|
||||
path: ['default_vat_treatment'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateAccountParams = z.infer<typeof CreateAccountParamsSchema>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -150,8 +150,9 @@ export async function findRcBasisGaps(
|
||||
|
||||
const basisByEntryAndRate = new Map<string, number>()
|
||||
for (const line of siblingLines) {
|
||||
const rate = STATIC_RC_BASIS_RATE.get(line.account_number) ??
|
||||
dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
|
||||
const rate = dynamicVatAccounts.explicitAccounts.has(line.account_number)
|
||||
? dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
|
||||
: STATIC_RC_BASIS_RATE.get(line.account_number)
|
||||
if (rate) {
|
||||
const key = `${line.journal_entry_id}:${rate}`
|
||||
const prev = basisByEntryAndRate.get(key) || 0
|
||||
|
||||
@@ -436,6 +436,7 @@ export function rutorFromTotals(
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
if (dynamic?.explicitAccounts.has(account)) continue
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit'
|
||||
@@ -445,7 +446,6 @@ export function rutorFromTotals(
|
||||
}
|
||||
|
||||
for (const [account, mapping] of dynamic?.mappingByAccount ?? []) {
|
||||
if (ACCOUNT_RUTA[account]) continue
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit' ? t.credit - t.debit : t.debit - t.credit
|
||||
@@ -561,6 +561,7 @@ export async function calculateVatDeclaration(
|
||||
}
|
||||
const RATE_BUCKET = { 0.25: 'base25', 0.12: 'base12', 0.06: 'base6' } as const
|
||||
for (const [account, rate] of [['3001', 'base25'], ['3002', 'base12'], ['3003', 'base6']] as const) {
|
||||
if (dynamicVatAccounts.explicitAccounts.has(account)) continue
|
||||
const t = totals.get(account)
|
||||
if (t) revenueByRate[rate] = round(t.credit - t.debit)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
VatDeclarationCheck,
|
||||
VatCheckAccountTotals,
|
||||
} from './vat-declaration-checks'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
/**
|
||||
* The filing gate for the momsdeklaration: ONE derived value that the
|
||||
@@ -94,12 +94,6 @@ export const RC_BASIS_ACCOUNTS_BY_RATE = {
|
||||
r6: ['4517', '4537', '4533', '4417', '4427'],
|
||||
} as const
|
||||
|
||||
const STATIC_RC_BASIS_ACCOUNTS = new Set<string>([
|
||||
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
|
||||
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
|
||||
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
|
||||
])
|
||||
|
||||
/** Net debit balance of the RC basis accounts, one figure per momssats. */
|
||||
export interface RcBasisTotalsByRate {
|
||||
r25: number
|
||||
@@ -120,6 +114,7 @@ export function rcBasisTotalsByRate(
|
||||
const sumGroup = (accounts: readonly string[]): number => {
|
||||
let sum = 0
|
||||
for (const account of accounts) {
|
||||
if (dynamic?.explicitAccounts.has(account)) continue
|
||||
const t = totals.get(account)
|
||||
if (t) sum += t.debit - t.credit
|
||||
}
|
||||
@@ -131,7 +126,6 @@ export function rcBasisTotalsByRate(
|
||||
r6: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r6),
|
||||
}
|
||||
for (const [account, rate] of dynamic?.rcBasisRateByAccount ?? []) {
|
||||
if (STATIC_RC_BASIS_ACCOUNTS.has(account)) continue
|
||||
const total = totals.get(account)
|
||||
if (!total) continue
|
||||
const key = rate === 0.25 ? 'r25' : rate === 0.12 ? 'r12' : 'r6'
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { VatDeclarationRutor } from '@/types'
|
||||
export const ACCOUNT_VAT_TREATMENTS = [
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'export_goods', 'export_services',
|
||||
'vmb', 'rental_voluntary',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
|
||||
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
|
||||
] as const
|
||||
|
||||
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
|
||||
export type AccountVatRate = 0 | 0.06 | 0.12 | 0.25 | null
|
||||
|
||||
export interface AccountVatRutaMapping {
|
||||
box: keyof VatDeclarationRutor
|
||||
@@ -18,6 +19,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
|
||||
standard_25: 'ruta05', reduced_12: 'ruta05', reduced_6: 'ruta05',
|
||||
exempt: 'ruta42', reverse_charge_domestic: 'ruta41',
|
||||
reverse_charge_eu_goods: 'ruta35', reverse_charge_eu_services: 'ruta39',
|
||||
reverse_charge_non_eu_services: null,
|
||||
export_goods: 'ruta36', export_services: 'ruta40', vmb: 'ruta07',
|
||||
rental_voluntary: 'ruta08',
|
||||
}
|
||||
@@ -25,6 +27,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
|
||||
export function resolveVatTreatmentRuta(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
accountNumber?: string,
|
||||
): AccountVatRutaMapping | null {
|
||||
if (accountClass === 3) {
|
||||
const box = REVENUE_RUTA[treatment]
|
||||
@@ -33,25 +36,40 @@ export function resolveVatTreatmentRuta(
|
||||
if (accountClass < 4 || accountClass > 6) return null
|
||||
if (treatment === 'reverse_charge_eu_goods') return { box: 'ruta20', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_eu_services') return { box: 'ruta21', side: 'debit' }
|
||||
if (treatment === 'export_services') return { box: 'ruta22', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_non_eu_services') return { box: 'ruta22', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_domestic') {
|
||||
return { box: accountClass === 4 ? 'ruta23' : 'ruta24', side: 'debit' }
|
||||
const isKnownServiceAccount = accountNumber != null && /^442[567]$/.test(accountNumber)
|
||||
return { box: accountClass === 4 && !isKnownServiceAccount ? 'ruta23' : 'ruta24', side: 'debit' }
|
||||
}
|
||||
if (treatment === 'export_goods') return { box: 'ruta50', side: 'debit' }
|
||||
return null
|
||||
}
|
||||
|
||||
export function isVatTreatmentAllowedForAccountClass(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): boolean {
|
||||
return resolveVatTreatmentRuta(treatment, accountClass) !== null
|
||||
}
|
||||
|
||||
export function vatTreatmentsForAccountClass(accountClass: number | null): AccountVatTreatment[] {
|
||||
if (accountClass === null) return []
|
||||
return ACCOUNT_VAT_TREATMENTS.filter((treatment) =>
|
||||
isVatTreatmentAllowedForAccountClass(treatment, accountClass)
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultRateForVatTreatment(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): number | null {
|
||||
): AccountVatRate {
|
||||
if (treatment === 'standard_25') return 0.25
|
||||
if (treatment === 'reduced_12') return 0.12
|
||||
if (treatment === 'reduced_6') return 0.06
|
||||
if (treatment === 'exempt') return 0
|
||||
if (treatment === 'rental_voluntary') return 0.25
|
||||
if (treatment === 'vmb') return null
|
||||
return accountClass >= 4 && accountClass <= 6 ? null : 0
|
||||
if (treatment === 'rental_voluntary') return 0.25
|
||||
if (treatment === 'export_goods' || treatment === 'export_services') return 0
|
||||
return accountClass >= 4 && accountClass <= 6 ? 0.25 : 0
|
||||
}
|
||||
|
||||
export function isAccountVatTreatment(value: unknown): value is AccountVatTreatment {
|
||||
@@ -59,19 +77,6 @@ export function isAccountVatTreatment(value: unknown): value is AccountVatTreatm
|
||||
(ACCOUNT_VAT_TREATMENTS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function vatTreatmentsForAccountClass(accountClass: number): AccountVatTreatment[] {
|
||||
return ACCOUNT_VAT_TREATMENTS.filter(
|
||||
(treatment) => resolveVatTreatmentRuta(treatment, accountClass) !== null,
|
||||
)
|
||||
}
|
||||
|
||||
export function isVatTreatmentValidForAccountClass(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): boolean {
|
||||
return resolveVatTreatmentRuta(treatment, accountClass) !== null
|
||||
}
|
||||
|
||||
export interface SuggestedVatTreatment {
|
||||
treatment: AccountVatTreatment
|
||||
rate: number | null
|
||||
@@ -90,19 +95,17 @@ export function suggestVatTreatment(
|
||||
if (accountClass < 3 || accountClass > 6) return null
|
||||
const name = accountName.toLocaleLowerCase('sv-SE')
|
||||
const percent = /\b(25|12|6)\s*%/.exec(name)
|
||||
const rate = percent ? Number(percent[1]) / 100 : null
|
||||
const rate = percent ? Number(percent[1]) / 100 : 0.25
|
||||
|
||||
if (accountClass === 3) {
|
||||
if (/vmb|vinstmarginal/.test(name)) return { treatment: 'vmb', rate: null }
|
||||
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) {
|
||||
return { treatment: 'rental_voluntary', rate: rate ?? 0.25 }
|
||||
}
|
||||
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) return { treatment: 'rental_voluntary', rate }
|
||||
if (/omvänd/.test(name)) return { treatment: 'reverse_charge_domestic', rate: 0 }
|
||||
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
|
||||
if (/export|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate: 0 }
|
||||
if (/export|utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate: 0 }
|
||||
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate: 0 }
|
||||
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate: 0 }
|
||||
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
|
||||
if (/försälj|forsalj|intäkt|intakt/.test(name) && percent) {
|
||||
return {
|
||||
treatment: rate === 0.12 ? 'reduced_12' : rate === 0.06 ? 'reduced_6' : 'standard_25',
|
||||
@@ -113,8 +116,10 @@ export function suggestVatTreatment(
|
||||
}
|
||||
|
||||
if (/omvänd/.test(name) && /sverige|svensk|inrikes/.test(name)) return { treatment: 'reverse_charge_domestic', rate }
|
||||
if (/import|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate }
|
||||
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate }
|
||||
if (/utanför eu|import/.test(name) && /var/.test(name)) return null
|
||||
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) {
|
||||
return { treatment: 'reverse_charge_non_eu_services', rate }
|
||||
}
|
||||
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate }
|
||||
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate }
|
||||
return null
|
||||
|
||||
+2
-1
@@ -4987,7 +4987,8 @@
|
||||
"vat_treatment_reverse_charge_domestic": "Domestic reverse charge",
|
||||
"vat_treatment_reverse_charge_eu_goods": "EU goods",
|
||||
"vat_treatment_reverse_charge_eu_services": "EU services",
|
||||
"vat_treatment_export_goods": "Export or import of goods",
|
||||
"vat_treatment_reverse_charge_non_eu_services": "Services purchased outside the EU",
|
||||
"vat_treatment_export_goods": "Goods exported outside the EU",
|
||||
"vat_treatment_export_services": "Services outside the EU",
|
||||
"vat_treatment_vmb": "Margin scheme (box 07)",
|
||||
"vat_treatment_rental_voluntary": "Voluntary VAT on rental income (box 08)",
|
||||
|
||||
+2
-1
@@ -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)",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
]
|
||||
},
|
||||
"naiveOreRound": {
|
||||
"count": 631
|
||||
"count": 630
|
||||
},
|
||||
"handRolledInvariants": {
|
||||
"count": 115
|
||||
|
||||
@@ -30,7 +30,7 @@ Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
accounts: { account_number: string, account_name: string, account_class: number, account_group: string, account_type: string, normal_balance: string, is_system_account: boolean, is_active: boolean, description: string, default_vat_code: string, sru_code: string, sort_order: number }[]
|
||||
accounts: { account_number: string, account_name: string, account_class: number, account_group: string, account_type: string, normal_balance: string, is_system_account: boolean, is_active: boolean, description: string, default_vat_code: string, default_vat_rate: number, default_vat_treatment: string, sru_code: string, sort_order: number }[]
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The predecessor constraint does not recognize the corrected non-EU service
|
||||
-- vocabulary. Remove it before the next migration normalizes legacy values;
|
||||
-- the class-aware constraint is restored immediately afterwards.
|
||||
ALTER TABLE public.chart_of_accounts
|
||||
DROP CONSTRAINT IF EXISTS chart_of_accounts_default_vat_treatment_check;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- PR #1588 briefly allowed purchase accounts to store revenue-side treatment
|
||||
-- names. Preserve the supported non-EU service meaning and clear the
|
||||
-- unsupported import-goods value before the class-aware constraint is added.
|
||||
UPDATE public.chart_of_accounts
|
||||
SET default_vat_treatment = 'reverse_charge_non_eu_services'
|
||||
WHERE account_class BETWEEN 4 AND 6
|
||||
AND default_vat_treatment = 'export_services';
|
||||
|
||||
UPDATE public.chart_of_accounts
|
||||
SET default_vat_treatment = NULL
|
||||
WHERE account_class BETWEEN 4 AND 6
|
||||
AND default_vat_treatment = 'export_goods';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- The predecessor API validated enum membership but not account class. After
|
||||
-- preserving the one lossless legacy rename, clear every remaining invalid
|
||||
-- combination before the class-aware constraint is installed.
|
||||
UPDATE public.chart_of_accounts
|
||||
SET default_vat_treatment = NULL
|
||||
WHERE default_vat_treatment IS NOT NULL
|
||||
AND NOT (
|
||||
(
|
||||
account_class = 3
|
||||
AND default_vat_treatment IN (
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'export_goods', 'export_services',
|
||||
'vmb', 'rental_voluntary'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
account_class BETWEEN 4 AND 6
|
||||
AND default_vat_treatment IN (
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services'
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE public.chart_of_accounts
|
||||
DROP CONSTRAINT IF EXISTS chart_of_accounts_default_vat_treatment_check;
|
||||
|
||||
ALTER TABLE public.chart_of_accounts
|
||||
ADD CONSTRAINT chart_of_accounts_default_vat_treatment_check
|
||||
CHECK (
|
||||
default_vat_treatment IS NULL
|
||||
OR (
|
||||
account_class = 3
|
||||
AND default_vat_treatment IN (
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'export_goods', 'export_services',
|
||||
'vmb', 'rental_voluntary'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
account_class BETWEEN 4 AND 6
|
||||
AND default_vat_treatment IN (
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services'
|
||||
)
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE public.chart_of_accounts
|
||||
VALIDATE CONSTRAINT chart_of_accounts_default_vat_treatment_check;
|
||||
|
||||
COMMENT ON COLUMN public.chart_of_accounts.default_vat_treatment IS
|
||||
'Per-account momsdeklaration treatment. Explicit values override the built-in BAS account mapping.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,47 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
async function setTreatment(companyId: string, treatment: string | null) {
|
||||
const NORMALIZE_SQL = readFileSync(
|
||||
join(process.cwd(), 'supabase/migrations/20260815150100_normalize_legacy_account_vat_treatments.sql'),
|
||||
'utf8',
|
||||
)
|
||||
const DROP_LEGACY_CONSTRAINT_SQL = readFileSync(
|
||||
join(process.cwd(), 'supabase/migrations/20260815150000_drop_legacy_account_vat_treatment_constraint.sql'),
|
||||
'utf8',
|
||||
)
|
||||
const ENFORCE_SQL = readFileSync(
|
||||
join(process.cwd(), 'supabase/migrations/20260815150300_enforce_class_aware_account_vat_treatment.sql'),
|
||||
'utf8',
|
||||
)
|
||||
const CLEAR_INCOMPATIBLE_SQL = readFileSync(
|
||||
join(process.cwd(), 'supabase/migrations/20260815150200_clear_incompatible_account_vat_treatments.sql'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
async function setTreatment(companyId: string, accountNumber: string, treatment: string | null) {
|
||||
return getPool().query(
|
||||
`UPDATE public.chart_of_accounts
|
||||
SET default_vat_treatment = $2
|
||||
WHERE company_id = $1 AND account_number = '3013'`,
|
||||
[companyId, treatment],
|
||||
WHERE company_id = $1 AND account_number = $3`,
|
||||
[companyId, treatment, accountNumber],
|
||||
)
|
||||
}
|
||||
|
||||
async function insertAccount(companyId: string, userId: string) {
|
||||
return getPool().query(
|
||||
async function insertAccount(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
accountNumber: string,
|
||||
accountClass: number,
|
||||
) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(user_id, company_id, account_number, account_name, account_class,
|
||||
account_group, account_type, normal_balance, plan_type,
|
||||
is_system_account)
|
||||
VALUES ($1, $2, '3013', 'Test account', 3, '30', 'revenue', 'credit',
|
||||
'full_bas', false)
|
||||
RETURNING account_number`,
|
||||
[userId, companyId],
|
||||
account_group, account_type, normal_balance, plan_type, is_system_account)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', false)`,
|
||||
[
|
||||
userId,
|
||||
companyId,
|
||||
accountNumber,
|
||||
`Test account ${accountNumber}`,
|
||||
accountClass,
|
||||
accountNumber.slice(0, 2),
|
||||
accountClass === 3 ? 'revenue' : 'expense',
|
||||
accountClass === 3 ? 'credit' : 'debit',
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
describe('chart_of_accounts.default_vat_treatment', () => {
|
||||
it('accepts every supported treatment and NULL', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
expect((await insertAccount(companyId, userId)).rowCount).toBe(1)
|
||||
const treatments = [
|
||||
await insertAccount(companyId, userId, '3001', 3)
|
||||
await insertAccount(companyId, userId, '4010', 4)
|
||||
const revenueTreatments = [
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'export_goods', 'export_services',
|
||||
'vmb', 'rental_voluntary', null,
|
||||
]
|
||||
for (const treatment of treatments) {
|
||||
await expect(setTreatment(companyId, treatment)).resolves.toMatchObject({ rowCount: 1 })
|
||||
for (const treatment of revenueTreatments) {
|
||||
await expect(setTreatment(companyId, '3001', treatment)).resolves.toBeDefined()
|
||||
}
|
||||
const purchaseTreatments = [
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services', null,
|
||||
]
|
||||
for (const treatment of purchaseTreatments) {
|
||||
await expect(setTreatment(companyId, '4010', treatment)).resolves.toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown treatments', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
expect((await insertAccount(companyId, userId)).rowCount).toBe(1)
|
||||
await expect(setTreatment(companyId, 'unknown')).rejects.toThrow()
|
||||
await insertAccount(companyId, userId, '3001', 3)
|
||||
await expect(setTreatment(companyId, '3001', 'unknown')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a treatment that is incompatible with the account class', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
await insertAccount(companyId, userId, '4010', 4)
|
||||
await expect(setTreatment(companyId, '4010', 'standard_25')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('normalizes values accepted by the predecessor before enforcing classes', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
await insertAccount(companyId, userId, '1010', 1)
|
||||
await insertAccount(companyId, userId, '4010', 4)
|
||||
await insertAccount(companyId, userId, '4011', 4)
|
||||
await insertAccount(companyId, userId, '4012', 4)
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(DROP_LEGACY_CONSTRAINT_SQL)
|
||||
await client.query(`
|
||||
UPDATE public.chart_of_accounts
|
||||
SET default_vat_treatment = CASE account_number
|
||||
WHEN '1010' THEN 'standard_25'
|
||||
WHEN '4010' THEN 'export_services'
|
||||
WHEN '4011' THEN 'export_goods'
|
||||
WHEN '4012' THEN 'standard_25'
|
||||
END
|
||||
WHERE company_id = $1 AND account_number IN ('1010', '4010', '4011', '4012')
|
||||
`, [companyId])
|
||||
|
||||
await client.query(NORMALIZE_SQL)
|
||||
await client.query(CLEAR_INCOMPATIBLE_SQL)
|
||||
await client.query(ENFORCE_SQL)
|
||||
|
||||
const result = await client.query<{
|
||||
account_number: string
|
||||
default_vat_treatment: string | null
|
||||
}>(`
|
||||
SELECT account_number, default_vat_treatment
|
||||
FROM public.chart_of_accounts
|
||||
WHERE company_id = $1 AND account_number IN ('1010', '4010', '4011', '4012')
|
||||
ORDER BY account_number
|
||||
`, [companyId])
|
||||
expect(result.rows).toEqual([
|
||||
{ account_number: '1010', default_vat_treatment: null },
|
||||
{ account_number: '4010', default_vat_treatment: 'reverse_charge_non_eu_services' },
|
||||
{ account_number: '4011', default_vat_treatment: null },
|
||||
{ account_number: '4012', default_vat_treatment: null },
|
||||
])
|
||||
} finally {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user