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

Closes #1457
This commit is contained in:
Mattsson
2026-08-13 17:03:35 +02:00
committed by GitHub
parent 7881f757a9
commit d02fd82191
32 changed files with 1196 additions and 253 deletions
+2
View File
@@ -932,3 +932,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client.
[2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees.
[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.
+51 -10
View File
@@ -62,6 +62,11 @@ import type {
ImportResult,
ParseIssue,
} from '@/lib/import/types'
import {
applyVatTreatmentReview,
enrichAccountMappingsWithVat,
} from '@/lib/import/account-vat-treatment'
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import type { TheaterModel } from '@/lib/import/theater-model'
/** Above this size the client-side theater parse is skipped (main-thread
@@ -513,7 +518,11 @@ function SIEImportWizard() {
// Skip the mapping step when all accounts are already mapped
const hasUnmapped = mappings.some((m) => !m.targetAccount)
const sieSteps: ImportWizardStep[] = hasUnmapped
const needsVatReview = mappings.some((m) =>
m.requiresVatTreatmentReview && !m.vatTreatmentReviewed
)
const showMappingStep = hasUnmapped || needsVatReview
const sieSteps: ImportWizardStep[] = showMappingStep
? ['upload', 'preview', 'mapping', 'review', 'result']
: ['upload', 'preview', 'review', 'result']
@@ -589,7 +598,6 @@ function SIEImportWizard() {
issues: data.parsed.issues,
stats: data.parsed.stats,
})
setMappings(data.mappings)
setPreview(data.preview)
setIssues(data.parsed.issues)
setSieAccounts(data.parsed.accounts)
@@ -597,7 +605,11 @@ function SIEImportWizard() {
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (accountsRes.ok) {
const accountsData = await accountsRes.json()
setBasAccounts(accountsData.data || [])
const accounts = accountsData.data || []
setBasAccounts(accounts)
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
} else {
setMappings(enrichAccountMappingsWithVat(data.mappings, []))
}
setStep('preview')
@@ -711,6 +723,27 @@ function SIEImportWizard() {
})
}, [mappings])
const handleVatTreatmentChange = useCallback((
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => {
setMappings((prev) => applyVatTreatmentReview(prev, sourceAccount, treatment, rate))
}, [])
const confirmVatReview = useCallback(() => {
if (mappings.some((mapping) =>
mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed
)) {
setError('Granska momshanteringen för alla markerade konton innan du fortsätter.')
return
}
setStep('review')
setError(null)
setValidationErrors([])
setValidationWarnings([])
}, [mappings])
const missingAccounts = mappings
.filter((m) => !m.targetAccount)
.map((m) => ({ number: m.sourceAccount, name: m.sourceName }))
@@ -738,11 +771,11 @@ function SIEImportWizard() {
// Optimistically update mappings: mark created accounts as self-mapped
const createdSet = new Set(missingAccounts.map(a => a.number))
setMappings(prev => prev.map(m =>
setMappings(prev => enrichAccountMappingsWithVat(prev.map(m =>
!m.targetAccount && createdSet.has(m.sourceAccount)
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
: m
))
), basAccounts))
setPreview(prev => {
if (!prev) return prev
const newMapped = prev.mappingStatus.mapped + createdSet.size
@@ -760,14 +793,16 @@ function SIEImportWizard() {
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (accountsRes.ok) {
const accountsData = await accountsRes.json()
setBasAccounts(accountsData.data || [])
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)
}
}, [missingAccounts, toast])
}, [basAccounts, missingAccounts, toast])
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
if (!file) { setError('No file selected'); return }
@@ -887,11 +922,12 @@ function SIEImportWizard() {
{step === 'preview' && preview && (
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} />
onContinue={() => goToStep(showMappingStep ? 'mapping' : 'review')} onBack={goBack} />
)}
{step === 'mapping' && (
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
onMappingChange={handleMappingChange} onContinue={() => goToStep('review')} onBack={goBack} />
onMappingChange={handleMappingChange} onVatTreatmentChange={handleVatTreatmentChange}
onContinue={confirmVatReview} onBack={goBack} />
)}
{step === 'review' && preview && (
<ImportReviewStep preview={preview} mappings={mappings}
@@ -900,7 +936,12 @@ function SIEImportWizard() {
)}
{step === 'result' && importResult && (
<ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo}
preview={preview} theaterModel={theaterModel} />
preview={preview} theaterModel={theaterModel}
unresolvedVatAccountCount={mappings.filter((mapping) =>
mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) &&
!mapping.vatTreatmentReviewed
).length} />
)}
</div>
)
+15 -1
View File
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { sparsePatchBody } from '@/lib/api/sparse-patch'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment'
// DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active).
@@ -95,7 +96,7 @@ export const PUT = withRouteContext(
// .default() today, so sparsePatchBody is a no-op here: it is the
// structural guarantee that adding one later cannot make a PUT that
// renames an account also rewrite its VAT code or SRU mapping. An
// explicit null (clearing sru_code, default_vat_code, default_vat_rate)
// explicit null (clearing sru_code, VAT defaults, or descriptions)
// still survives.
const validation = await validateBody(request, sparsePatchBody(UpdateAccountSchema), {
log,
@@ -104,6 +105,19 @@ export const PUT = withRouteContext(
if (!validation.success) return validation.response
const body = validation.data
if (
body.default_vat_treatment &&
!isVatTreatmentValidForAccountClass(
body.default_vat_treatment,
Number(number.charAt(0)),
)
) {
return NextResponse.json(
{ error: 'Momshanteringen är inte giltig för kontoklassen' },
{ status: 400 },
)
}
if (Object.keys(body).length === 0) {
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
}
@@ -267,6 +267,28 @@ describe('POST /api/bookkeeping/accounts', () => {
}
expect(insertArg?.default_vat_rate).toBe(0)
})
it('forwards default_vat_treatment into the insert', async () => {
const { supabase, calls } = createCapturingSupabase([{
data: { account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods' },
}])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
method: 'POST',
body: {
account_number: '4056',
account_name: 'Inköp varor EU',
account_type: 'expense',
normal_balance: 'debit',
default_vat_treatment: 'reverse_charge_eu_goods',
},
})
expect((await createPOST(req, routeParams)).status).toBe(200)
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
default_vat_treatment?: string | null
}
expect(insertArg.default_vat_treatment).toBe('reverse_charge_eu_goods')
})
})
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
@@ -376,6 +398,33 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => {
expect(updateArg?.default_vat_rate).toBe(0)
})
it('forwards default_vat_treatment into the update', async () => {
const { supabase, calls } = createCapturingSupabase([{
data: { account_number: '3041', default_vat_treatment: 'standard_25' },
}])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/3041', {
method: 'PUT',
body: { default_vat_treatment: 'standard_25' },
})
expect((await PUT(req, { params: Promise.resolve({ number: '3041' }) })).status).toBe(200)
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
default_vat_treatment?: string | null
}
expect(updateArg.default_vat_treatment).toBe('standard_25')
})
it('rejects a VAT treatment that does not apply to the account class', async () => {
const { supabase, calls } = createCapturingSupabase([])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
method: 'PUT',
body: { default_vat_treatment: 'standard_25' },
})
expect((await PUT(req, numberParams)).status).toBe(400)
expect(calls.some((call) => call.method === 'update')).toBe(false)
})
// The body is spread straight into .update(), so the write set must be
// exactly what the caller named. UpdateAccountSchema carries no .default()
// today; these two lock the property in so adding one cannot turn a rename
+1
View File
@@ -104,6 +104,7 @@ export const POST = withRouteContext(
description: body.description || null,
default_vat_code: body.default_vat_code || null,
default_vat_rate: body.default_vat_rate ?? null,
default_vat_treatment: body.default_vat_treatment ?? null,
sru_code: body.sru_code || null,
sort_order: parseInt(body.account_number),
})
@@ -31,10 +31,19 @@ function buildSupabase(
chartAccounts: Array<{
account_number: string
account_name?: string
account_class?: number
default_vat_rate: number | null
default_vat_treatment?: string | null
}> = []
): SupabaseShape {
const chartResult = { data: chartAccounts, error: null }
const chartResult = {
data: chartAccounts.map((account) => ({
account_class: 3,
default_vat_treatment: null,
...account,
})),
error: null,
}
return {
rpc: vi.fn().mockResolvedValue(linesResult),
from: vi.fn().mockImplementation((table: string) => {
@@ -44,6 +53,8 @@ function buildSupabase(
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
not: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
@@ -228,7 +239,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
})
})
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts', () => {
describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: account overrides', () => {
/** The p_accounts array the route handed to get_vat_ruta_source_lines. */
function rpcAccounts(supabase: SupabaseShape): string[] {
return (supabase.rpc.mock.calls[0][1] as { p_accounts: string[] }).p_accounts
@@ -280,7 +291,23 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts
const accounts = rpcAccounts(supabase)
expect(accounts).toContain('2611')
expect(accounts).not.toContain('3013')
expect(supabase.from).not.toHaveBeenCalledWith('chart_of_accounts')
// Every ruta resolves explicit account overrides before the static BAS
// fallback, but a ruta 05-only custom account still stays out of ruta 10.
expect(supabase.from).toHaveBeenCalledWith('chart_of_accounts')
})
it('drills into a custom EU purchase account in ruta 20', async () => {
const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [{
account_number: '4056',
account_name: 'Inköp varor 25% EU',
account_class: 4,
default_vat_rate: 0.25,
default_vat_treatment: 'reverse_charge_eu_goods',
}])
authOk(supabase)
expect((await get('20')).status).toBe(200)
expect(rpcAccounts(supabase)).toContain('4056')
})
})
@@ -4,7 +4,7 @@ import {
ACCOUNT_RUTA,
resolvePeriodDates,
} from '@/lib/reports/vat-declaration'
import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts'
import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
import type { VatDeclarationRutor, VatPeriodType } from '@/types'
@@ -40,17 +40,17 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
rutaParam.startsWith('ruta') ? rutaParam : `ruta${rutaParam}`
) as keyof VatDeclarationRutor
// Invert ACCOUNT_RUTA: which BAS accounts feed this ruta?
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
// Invert the effective mapping. Fixed BAS mappings stay authoritative;
// explicit treatments add custom accounts only.
const accountsForRuta = Object.entries(ACCOUNT_RUTA)
.filter(([, m]) => m.box === rutaKey)
.map(([acc]) => acc)
// Ruta 05 also collects the company's own momspliktiga intäktskonton, which
// ACCOUNT_RUTA cannot know about (#1261). Without them the drill-down would
// list a smaller sum than the figure it drills into.
if (rutaKey === 'ruta05') {
const { accounts } = await fetchDynamicRuta05Accounts(supabase, companyId)
accountsForRuta.push(...accounts)
for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) {
if (ACCOUNT_RUTA[account]) continue
if (mapping.box === rutaKey) accountsForRuta.push(account)
}
if (accountsForRuta.length === 0) {
@@ -0,0 +1,52 @@
'use client'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
vatTreatmentsForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
interface AccountVatTreatmentSelectProps {
value: AccountVatTreatment | 'none'
onValueChange: (value: AccountVatTreatment | 'none') => void
accountClass: number | null
}
export function AccountVatTreatmentSelect({
value,
onValueChange,
accountClass,
}: AccountVatTreatmentSelectProps) {
const t = useTranslations('chart_of_accounts')
const isRelevant = accountClass === 3 ||
(accountClass !== null && accountClass >= 4 && accountClass <= 6)
const treatments = accountClass === null ? [] : vatTreatmentsForAccountClass(accountClass)
return (
<div className="space-y-2">
<Label>{t('vat_treatment_label')}</Label>
<Select
value={value}
onValueChange={(next) => onValueChange(next as AccountVatTreatment | 'none')}
disabled={!isRelevant}
>
<SelectTrigger aria-label={t('vat_treatment_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{treatments.map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{isRelevant ? t('vat_treatment_help') : t('vat_treatment_not_applicable')}
</p>
</div>
)
}
+21 -6
View File
@@ -19,6 +19,11 @@ import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import type { BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
/**
* The create path hands back the full row the API inserted. The reactivate
@@ -49,6 +54,7 @@ export function AddAccountDialog({
// "Standard moms": the moms-sats a booking line defaults to when this konto is
// picked. 'none' = no default. SelectItem values are stringified decimals.
const [defaultVatRate, setDefaultVatRate] = useState('none')
const [defaultVatTreatment, setDefaultVatTreatment] = useState<AccountVatTreatment | 'none'>('none')
const [sruCode, setSruCode] = useState('')
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
@@ -102,6 +108,7 @@ export function AddAccountDialog({
normal_balance: normalBalance,
description: description || null,
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment,
sru_code: sruCode || null,
}),
})
@@ -127,6 +134,7 @@ export function AddAccountDialog({
setAccountName('')
setDescription('')
setDefaultVatRate('none')
setDefaultVatTreatment('none')
setSruCode('')
onCreated(createdAccount)
onOpenChange(false)
@@ -162,6 +170,7 @@ export function AddAccountDialog({
setAccountName('')
setDescription('')
setDefaultVatRate('none')
setDefaultVatTreatment('none')
setSruCode('')
onCreated({ account_number: accountNumber })
onOpenChange(false)
@@ -260,6 +269,18 @@ export function AddAccountDialog({
/>
</div>
<AccountVatTreatmentSelect
value={defaultVatTreatment}
accountClass={derived ? Number(accountNumber.charAt(0)) : null}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
const rate = defaultRateForVatTreatment(treatment, Number(accountNumber.charAt(0)))
setDefaultVatRate(rate === null ? 'none' : String(rate))
}
}}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard moms <span className="text-muted-foreground">(valfritt)</span></Label>
@@ -275,12 +296,6 @@ export function AddAccountDialog({
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{accountNumber.length === 4 && accountNumber.startsWith('3') && (
<p className="text-xs text-muted-foreground">
På intäktskonton avgör satsen också om kontot räknas som
momspliktig försäljning i ruta 05 i momsdeklarationen.
</p>
)}
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
+21 -6
View File
@@ -32,6 +32,11 @@ import {
type DimensionRuleType,
} from '@/components/dimensions/types'
import type { BASAccount } from '@/types'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
interface EditAccountDialogProps {
open: boolean
@@ -65,6 +70,9 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
const [defaultVatRate, setDefaultVatRate] = useState(
account.default_vat_rate != null ? String(account.default_vat_rate) : 'none',
)
const [defaultVatTreatment, setDefaultVatTreatment] = useState<AccountVatTreatment | 'none'>(
account.default_vat_treatment ?? 'none',
)
const [sruCode, setSruCode] = useState(account.sru_code || '')
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
@@ -256,6 +264,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
account_name: accountName,
description: description || null,
default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate),
default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment,
sru_code: sruCode || null,
is_active: isActive,
}),
@@ -313,6 +322,18 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
/>
</div>
<AccountVatTreatmentSelect
value={defaultVatTreatment}
accountClass={account.account_class}
onValueChange={(treatment) => {
setDefaultVatTreatment(treatment)
if (treatment !== 'none') {
const rate = defaultRateForVatTreatment(treatment, account.account_class)
setDefaultVatRate(rate === null ? 'none' : String(rate))
}
}}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard moms</Label>
@@ -328,12 +349,6 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{account.account_class === 3 && (
<p className="text-xs text-muted-foreground">
På intäktskonton avgör satsen också om kontot räknas som
momspliktig försäljning i ruta 05 i momsdeklarationen.
</p>
)}
</div>
<div className="space-y-2">
<Label>SRU-kod</Label>
+105 -5
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,16 +32,26 @@ import {
import type { AccountMapping } from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import {
defaultRateForVatTreatment,
vatTreatmentsForAccountClass,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
interface AccountMappingStepProps {
mappings: AccountMapping[]
basAccounts: BASAccount[]
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onVatTreatmentChange?: (
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
) => void
onContinue: () => void
onBack: () => void
}
type FilterType = 'all' | 'unmapped' | 'low_confidence' | 'manual'
type FilterType = 'all' | 'unmapped' | 'vat_review' | 'low_confidence' | 'manual'
const PAGE_SIZE = 50
@@ -48,14 +59,17 @@ export default function AccountMappingStep({
mappings,
basAccounts,
onMappingChange,
onVatTreatmentChange,
onContinue,
onBack,
}: AccountMappingStepProps) {
const t = useTranslations('chart_of_accounts')
const [searchTerm, setSearchTerm] = useState('')
// Default to showing unmapped accounts first (most actionable)
const [filter, setFilter] = useState<FilterType>(() => {
const hasUnmapped = mappings.some((m) => !m.targetAccount)
return hasUnmapped ? 'unmapped' : 'all'
const hasVatReview = mappings.some((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
return hasUnmapped ? 'unmapped' : hasVatReview ? 'vat_review' : 'all'
})
const [currentPage, setCurrentPage] = useState(1)
@@ -71,6 +85,9 @@ export default function AccountMappingStep({
case 'low_confidence':
result = result.filter((m) => m.targetAccount && m.confidence < 0.7)
break
case 'vat_review':
result = result.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed)
break
case 'manual':
result = result.filter((m) => m.isOverride)
break
@@ -114,10 +131,11 @@ export default function AccountMappingStep({
const unmapped = mappings.filter((m) => !m.targetAccount).length
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
const manual = mappings.filter((m) => m.isOverride).length
return { unmapped, lowConfidence, manual }
const vatReview = mappings.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed).length
return { unmapped, lowConfidence, manual, vatReview }
}, [mappings])
const canContinue = stats.unmapped === 0
const canContinue = stats.unmapped === 0 && stats.vatReview === 0
// Group BAS accounts by class for the dropdown
const accountsByClass = useMemo(() => {
@@ -145,6 +163,14 @@ export default function AccountMappingStep({
<CardContent className="space-y-4">
{/* Stats */}
<div className="flex gap-4 flex-wrap">
<Badge
variant={filter === 'vat_review' ? 'default' : stats.vatReview > 0 ? 'secondary' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('vat_review')}
>
<AlertCircle className="h-3 w-3 mr-1" />
{t('vat_review_filter', { count: stats.vatReview })}
</Badge>
<Badge
variant={filter === 'unmapped' ? 'destructive' : stats.unmapped > 0 ? 'destructive' : 'secondary'}
className="cursor-pointer"
@@ -197,6 +223,7 @@ export default function AccountMappingStep({
<SelectContent>
<SelectItem value="all">Visa alla</SelectItem>
<SelectItem value="unmapped">Ej mappade</SelectItem>
<SelectItem value="vat_review">{t('vat_review_filter', { count: stats.vatReview })}</SelectItem>
<SelectItem value="low_confidence">Osäkra</SelectItem>
<SelectItem value="manual">Manuellt satta</SelectItem>
</SelectContent>
@@ -211,6 +238,7 @@ 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="w-24">Konfidens</TableHead>
</TableRow>
@@ -226,6 +254,78 @@ export default function AccountMappingStep({
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{onVatTreatmentChange &&
mapping.sourceAccount === mapping.targetAccount &&
['3', '4', '5', '6'].includes(mapping.sourceAccount.charAt(0)) ? (
<div className="flex min-w-72 gap-2">
<Select
value={mapping.defaultVatTreatment ?? 'none'}
onValueChange={(value) => {
const treatment = value === 'none'
? null
: value as AccountVatTreatment
const accountClass = Number(mapping.sourceAccount.charAt(0))
const rate = treatment
? defaultRateForVatTreatment(treatment, accountClass)
: null
onVatTreatmentChange(mapping.sourceAccount, treatment, rate)
}}
>
<SelectTrigger className={mapping.requiresVatTreatmentReview ? 'border-warning/60' : ''}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_treatment_none')}</SelectItem>
{vatTreatmentsForAccountClass(
Number(mapping.sourceAccount.charAt(0)),
).map((treatment) => (
<SelectItem key={treatment} value={treatment}>
{t(`vat_treatment_${treatment}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={mapping.defaultVatRate === null || mapping.defaultVatRate === undefined
? 'none'
: String(mapping.defaultVatRate)}
onValueChange={(value) => onVatTreatmentChange(
mapping.sourceAccount,
mapping.defaultVatTreatment ?? null,
value === 'none' ? null : Number(value),
)}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('vat_rate_none')}</SelectItem>
<SelectItem value="0">0 %</SelectItem>
<SelectItem value="0.25">25 %</SelectItem>
<SelectItem value="0.12">12 %</SelectItem>
<SelectItem value="0.06">6 %</SelectItem>
</SelectContent>
</Select>
{mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onVatTreatmentChange(
mapping.sourceAccount,
mapping.defaultVatTreatment ?? null,
mapping.defaultVatRate ?? null,
)}
>
{t('vat_treatment_confirm')}
</Button>
)}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Select
value={mapping.targetAccount || 'none'}
@@ -275,7 +375,7 @@ export default function AccountMappingStep({
))}
{paginatedMappings.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
Inga konton matchar filtret
</TableCell>
</TableRow>
+19
View File
@@ -35,6 +35,7 @@ interface ImportResultStepProps {
* bridge. Absent (failure, oversized file, parse miss) = plain header. */
preview?: ImportPreview | null
theaterModel?: TheaterModel | null
unresolvedVatAccountCount?: number
}
export default function ImportResultStep({
@@ -43,6 +44,7 @@ export default function ImportResultStep({
onUndo,
preview = null,
theaterModel = null,
unresolvedVatAccountCount = 0,
}: ImportResultStepProps) {
const t = useTranslations('import')
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -193,6 +195,23 @@ export default function ImportResultStep({
</Card>
)}
{result.success && unresolvedVatAccountCount > 0 && (
<Card className="border-warning/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="h-5 w-5 text-warning" />
{t('vat_review_title', { count: unresolvedVatAccountCount })}
</CardTitle>
<CardDescription>{t('vat_review_description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/chart-of-accounts">{t('vat_review_action')}</Link>
</Button>
</CardContent>
</Card>
)}
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
{result.success && result.dimensionsImported && (
<Card>
+25
View File
@@ -1996,6 +1996,23 @@ 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)
})
})
// ============================================================
@@ -2305,6 +2322,14 @@ describe('UpdateAccountSchema', () => {
default_vat_rate: 0.5,
}).success).toBe(false)
})
it('accepts supported account VAT treatments and rejects unknown values', () => {
expect(UpdateAccountSchema.safeParse({
default_vat_treatment: 'reverse_charge_eu_goods',
}).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: null }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: 'eu_purchase' }).success).toBe(false)
})
})
// ============================================================
+26
View File
@@ -16,6 +16,7 @@ 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
@@ -2110,6 +2111,15 @@ const defaultVatRate = z
.nullable()
.optional()
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',
])
const defaultVatTreatment = AccountVatTreatmentSchema.nullable().optional()
export const CreateAccountSchema = z.object({
account_number: accountNumber,
account_name: z.string().min(1, 'Account name is required'),
@@ -2119,7 +2129,22 @@ export const CreateAccountSchema = z.object({
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
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({
@@ -2128,6 +2153,7 @@ export const UpdateAccountSchema = z.object({
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
default_vat_rate: defaultVatRate,
default_vat_treatment: defaultVatTreatment,
sru_code: z.string().nullable().optional(),
})
+19
View File
@@ -102,6 +102,25 @@ function run(
// --- Tests ---
describe('syncMappedAccounts: create pass', () => {
it('persists a reviewed VAT treatment on a created identity account', async () => {
const { supabase, inserts } = buildCapturingSupabase()
const result = await run(supabase, [
mapping({
sourceAccount: '4056',
targetAccount: '4056',
sourceName: 'Inköp varor 25% EU',
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: 0.25,
vatTreatmentReviewed: true,
}),
])
expect(result.error).toBeNull()
expect(inserts[0]).toMatchObject({
default_vat_treatment: 'reverse_charge_eu_goods',
default_vat_rate: 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()
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
applyVatTreatmentReview,
enrichAccountMappingsWithVat,
} from '../account-vat-treatment'
import type { AccountMapping } from '../types'
function mapping(account: string, name: string): AccountMapping {
return {
sourceAccount: account,
sourceName: name,
targetAccount: account,
targetName: name,
confidence: 1,
matchType: 'exact',
isOverride: false,
}
}
describe('enrichAccountMappingsWithVat', () => {
it('marks label suggestions for user review', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('4056', 'Inköp varor 25% EU')],
[],
)
expect(result).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: 0.25,
vatTreatmentSuggested: true,
vatTreatmentReviewed: false,
requiresVatTreatmentReview: true,
})
})
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')],
[{
account_number: '3041',
default_vat_treatment: 'standard_25',
default_vat_rate: 0.25,
} as never],
)
expect(result).toMatchObject({
defaultVatTreatment: 'standard_25',
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
requiresVatTreatmentReview: 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(
mappings,
'5010',
'reverse_charge_eu_services',
0.25,
)
expect(result[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_services',
defaultVatRate: 0.25,
vatTreatmentReviewed: true,
})
expect(result[1].vatTreatmentReviewed).toBeUndefined()
})
})
+45 -1
View File
@@ -14,6 +14,7 @@ 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'
export interface AccountSyncResult {
/** Accounts inserted into chart_of_accounts */
@@ -43,6 +44,7 @@ function buildInsertRow(
basRef: BASReferenceAccount | undefined,
companyId: string,
userId: string,
vatDefaults?: { treatment: AccountVatTreatment | null; rate: number | null },
) {
const sortOrder = /^\d+$/.test(accountNumber) ? parseInt(accountNumber, 10) : null
@@ -63,6 +65,8 @@ function buildInsertRow(
is_system_account: false,
description: basRef.description,
sort_order: sortOrder,
default_vat_treatment: vatDefaults?.treatment ?? null,
default_vat_rate: vatDefaults?.rate ?? null,
}
}
@@ -84,6 +88,8 @@ function buildInsertRow(
is_system_account: false,
description: accountName,
sort_order: sortOrder,
default_vat_treatment: vatDefaults?.treatment ?? null,
default_vat_rate: vatDefaults?.rate ?? null,
}
}
@@ -143,6 +149,27 @@ export async function syncMappedAccounts(
if (m.targetAccount && fallback) fallbackNames.set(m.targetAccount, fallback)
}
const vatDefaults = new Map<string, { treatment: AccountVatTreatment | null; rate: number | null }>()
for (const mapping of mappings) {
if (
!mapping.vatTreatmentReviewed ||
!mapping.targetAccount ||
mapping.sourceAccount !== mapping.targetAccount
) continue
if (
mapping.defaultVatTreatment !== null &&
mapping.defaultVatTreatment !== undefined &&
!isAccountVatTreatment(mapping.defaultVatTreatment)
) {
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
return result
}
vatDefaults.set(mapping.targetAccount, {
treatment: mapping.defaultVatTreatment ?? null,
rate: mapping.defaultVatRate ?? null,
})
}
// Fetch the company's chart once (paged) and filter in JS: avoids a huge
// .in() URL for full-chart imports and the silent 1000-row PostgREST cap.
let existingByNumber: Map<string, string>
@@ -181,7 +208,7 @@ export async function syncMappedAccounts(
basRef?.account_name ??
fallbackNames.get(num) ??
`Konto ${num}`
return buildInsertRow(num, name, basRef, companyId, userId)
return buildInsertRow(num, name, basRef, companyId, userId, vatDefaults.get(num))
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
@@ -195,6 +222,23 @@ export async function syncMappedAccounts(
result.created = missing.length
}
const existingVatUpdates = [...vatDefaults]
.filter(([account]) => existingByNumber.has(account))
for (const [account, defaults] of existingVatUpdates) {
const { error: vatUpdateError } = await supabase
.from('chart_of_accounts')
.update({
default_vat_treatment: defaults.treatment,
default_vat_rate: defaults.rate,
})
.eq('company_id', companyId)
.eq('account_number', account)
if (vatUpdateError) {
result.error = vatUpdateError.message
return result
}
}
// Rename pass: carry the file's names into existing accounts. The diff set
// is small (only names that actually changed), so the UPDATEs run
// concurrently in bounded batches: a full-chart re-sync must not serialize
+67
View File
@@ -0,0 +1,67 @@
import type { BASAccount } from '@/types'
import {
suggestVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
import type { AccountMapping } from './types'
/**
* Add reviewable VAT suggestions to identity mappings. SIE itself has no VAT
* treatment record, so suggestions come only from the account label and are
* never considered reviewed until the user continues from the mapping step.
*/
export function enrichAccountMappingsWithVat(
mappings: AccountMapping[],
existingAccounts: BASAccount[],
): AccountMapping[] {
const existingByNumber = new Map(
existingAccounts.map((account) => [account.account_number, account]),
)
return mappings.map((mapping) => {
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
const accountClass = Number(mapping.sourceAccount.charAt(0))
if (accountClass < 3 || accountClass > 6) return mapping
const existing = existingByNumber.get(mapping.targetAccount)
if (existing?.default_vat_treatment) {
return {
...mapping,
defaultVatTreatment: existing.default_vat_treatment,
defaultVatRate: existing.default_vat_rate,
vatTreatmentReviewed: true,
vatTreatmentSuggested: false,
requiresVatTreatmentReview: false,
}
}
const suggestion = suggestVatTreatment(mapping.sourceAccount, mapping.sourceName)
return {
...mapping,
defaultVatTreatment: suggestion?.treatment ?? null,
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
vatTreatmentSuggested: Boolean(suggestion),
vatTreatmentReviewed: false,
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
}
})
}
export function applyVatTreatmentReview(
mappings: AccountMapping[],
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
): AccountMapping[] {
return mappings.map((mapping) =>
mapping.sourceAccount === sourceAccount
? {
...mapping,
defaultVatTreatment: treatment,
defaultVatRate: rate,
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
}
: mapping
)
}
+5
View File
@@ -185,6 +185,11 @@ export interface AccountMapping {
confidence: number // 0-1
matchType: AccountMatchType
isOverride: boolean // User manually set this
defaultVatTreatment?: import('@/lib/vat/account-vat-treatment').AccountVatTreatment | null
defaultVatRate?: number | null
vatTreatmentSuggested?: boolean
vatTreatmentReviewed?: boolean
requiresVatTreatmentReview?: boolean
}
/**
@@ -21,6 +21,11 @@ vi.mock('@/lib/bookkeeping/entry-lines', () => ({
fetchLinesByEntryIds: (...args: unknown[]) => fetchLinesByEntryIdsMock(...args),
}))
const fetchDynamicVatAccountsMock = vi.fn()
vi.mock('../vat-revenue-accounts', () => ({
fetchDynamicVatAccounts: (...args: unknown[]) => fetchDynamicVatAccountsMock(...args),
}))
import { findRcBasisGaps } from '../rc-basis-gaps'
const supabase = {} as SupabaseClient
@@ -47,6 +52,10 @@ describe('findRcBasisGaps', () => {
resolvePeriodDatesMock.mockResolvedValue({ start: '2025-07-17', end: '2026-12-31' })
fetchEntryLinesMock.mockResolvedValue([])
fetchLinesByEntryIdsMock.mockResolvedValue([])
fetchDynamicVatAccountsMock.mockResolvedValue({
explicitAccounts: new Set(),
rcBasisRateByAccount: new Map(),
})
})
it('resolves the period via resolvePeriodDates with the fiscal period id', async () => {
@@ -124,4 +133,32 @@ describe('findRcBasisGaps', () => {
expect(gaps).toHaveLength(1)
expect(gaps[0].expectedBasisAmount).toBe(10000)
})
it('accepts a custom EU purchase basis account at the matching rate', async () => {
fetchDynamicVatAccountsMock.mockResolvedValue({
explicitAccounts: new Set(['4056']),
rcBasisRateByAccount: new Map([['4056', 0.25]]),
})
fetchEntryLinesMock.mockResolvedValue([rcLine('entry-1', 8, 250)])
fetchLinesByEntryIdsMock.mockResolvedValue([{
id: 'l-1', journal_entry_id: 'entry-1', account_number: '4056',
debit_amount: 1000, credit_amount: 0,
}])
await expect(findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5))
.resolves.toEqual([])
})
it('does not let a 25 percent basis cover 12 percent output VAT', async () => {
fetchEntryLinesMock.mockResolvedValue([{
...rcLine('entry-1', 8, 120),
account_number: '2624',
}])
fetchLinesByEntryIdsMock.mockResolvedValue([{
id: 'l-1', journal_entry_id: 'entry-1', account_number: '4535',
debit_amount: 1000, credit_amount: 0,
}])
const gaps = await findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5)
expect(gaps).toHaveLength(1)
expect(gaps[0].rate).toBe(0.12)
})
})
+39 -2
View File
@@ -16,7 +16,9 @@ let results: Array<{ data?: unknown; error?: unknown }>
let chartAccounts: Array<{
account_number: string
account_name?: string
account_class?: number
default_vat_rate: number | null
default_vat_treatment?: string | null
}>
function makeBuilder() {
@@ -37,12 +39,17 @@ function makeBuilder() {
*/
function makeChartBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'not', 'order', 'range']) {
for (const m of ['select', 'eq', 'gte', 'lte', 'in', 'not', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.then = (resolve: (v: unknown) => void) =>
resolve({
data: chartAccounts.map((account) => ({ account_name: '', ...account })),
data: chartAccounts.map((account) => ({
account_name: '',
account_class: 3,
default_vat_treatment: null,
...account,
})),
error: null,
})
return b
@@ -117,6 +124,36 @@ beforeEach(() => {
// Pure function tests: no mocks needed
// ============================================================
describe('rutorFromTotals: explicit account VAT treatments', () => {
it('puts a custom sales account in ruta 05', () => {
const totals = new Map([['3041', { debit: 0, credit: 1000 }]])
const rutor = rutorFromTotals(totals, {
mappingByAccount: new Map([['3041', { box: 'ruta05', side: 'credit' }]]),
explicitAccounts: new Set(['3041']),
})
expect(rutor.ruta05).toBe(1000)
})
it('puts a custom EU purchase account in ruta 20', () => {
const totals = new Map([['4056', { debit: 1000, credit: 0 }]])
const rutor = rutorFromTotals(totals, {
mappingByAccount: new Map([['4056', { box: 'ruta20', side: 'debit' }]]),
explicitAccounts: new Set(['4056']),
})
expect(rutor.ruta20).toBe(1000)
})
it('keeps a static BAS mapping authoritative over an explicit treatment', () => {
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)
})
})
describe('rutorFromTotals: ruta 41 (omvänd skattskyldighet, sales side)', () => {
it('projects 3231/3232/3233 credit balances into ruta 41', () => {
const totals = new Map([
+15 -10
View File
@@ -6,6 +6,7 @@ import {
} from '@/lib/bookkeeping/entry-lines'
import { resolvePeriodDates } from './vat-declaration'
import { RC_BASIS_ACCOUNTS_BY_RATE } from './vat-filing-gate'
import { fetchDynamicVatAccounts } from './vat-revenue-accounts'
import type { VatPeriodType } from '@/types'
/**
@@ -24,10 +25,10 @@ type RcOutputAccount = typeof RC_OUTPUT_ACCOUNTS[number]
// domestic goods RC (4415-4417), domestic services RC (4425-4427). Derived
// from the rate-grouped single source in vat-filing-gate.ts so this scan and
// the per-rate downgrade evidence can never disagree on the account set.
const RC_BASIS_ACCOUNTS = new Set<string>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
const STATIC_RC_BASIS_RATE = new Map<string, number>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25.map((account) => [account, 0.25] as const),
...RC_BASIS_ACCOUNTS_BY_RATE.r12.map((account) => [account, 0.12] as const),
...RC_BASIS_ACCOUNTS_BY_RATE.r6.map((account) => [account, 0.06] as const),
])
const RATE_BY_OUTPUT: Record<RcOutputAccount, number> = {
@@ -119,6 +120,7 @@ export async function findRcBasisGaps(
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
const rcLines = (await fetchEntryLines<unknown>({
@@ -146,12 +148,15 @@ export async function findRcBasisGaps(
'id, journal_entry_id, account_number, debit_amount, credit_amount',
)
const basisByEntry = new Map<string, number>()
const basisByEntryAndRate = new Map<string, number>()
for (const line of siblingLines) {
if (RC_BASIS_ACCOUNTS.has(line.account_number)) {
const prev = basisByEntry.get(line.journal_entry_id) || 0
basisByEntry.set(
line.journal_entry_id,
const rate = STATIC_RC_BASIS_RATE.get(line.account_number) ??
dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
if (rate) {
const key = `${line.journal_entry_id}:${rate}`
const prev = basisByEntryAndRate.get(key) || 0
basisByEntryAndRate.set(
key,
prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
)
}
@@ -179,7 +184,7 @@ export async function findRcBasisGaps(
const rate = RATE_BY_OUTPUT[account]
if (!rate) continue
const expectedBasis = Math.round((amount / rate) * 100) / 100
const actualBasis = basisByEntry.get(row.journal_entry_id) || 0
const actualBasis = basisByEntryAndRate.get(`${row.journal_entry_id}:${rate}`) || 0
if (actualBasis + eps >= expectedBasis) continue
const entry = pickEntry(row)
+20 -12
View File
@@ -6,7 +6,7 @@ import type {
} from '@/types'
import type { VatCheckAccountTotals } from './vat-declaration-checks'
import { rcBasisTotalsByRate } from './vat-filing-gate'
import { fetchDynamicRuta05Accounts } from './vat-revenue-accounts'
import { fetchDynamicVatAccounts, type DynamicVatAccounts } from './vat-revenue-accounts'
/**
* Calculate VAT declaration (Momsdeklaration) for a given period.
@@ -414,8 +414,16 @@ export async function fetchVatAccountTotals(
*/
export function rutorFromTotals(
totals: Map<string, { debit: number; credit: number }>,
dynamicRuta05Accounts: string[] = []
dynamicVatAccounts?: Pick<DynamicVatAccounts, 'mappingByAccount' | 'explicitAccounts'> | string[],
): VatDeclarationRutor {
const dynamic = Array.isArray(dynamicVatAccounts)
? {
mappingByAccount: new Map(dynamicVatAccounts.map((account) => [
account, { box: 'ruta05' as const, side: 'credit' as const },
])),
explicitAccounts: new Set<string>(),
}
: dynamicVatAccounts
const rutor: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
@@ -436,12 +444,12 @@ export function rutorFromTotals(
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// The company's own momspliktiga intäktskonton. Always credit-side: these are
// revenue accounts by construction (account_class 3).
for (const account of dynamicRuta05Accounts) {
for (const [account, mapping] of dynamic?.mappingByAccount ?? []) {
if (ACCOUNT_RUTA[account]) continue
const t = totals.get(account)
if (!t) continue
rutor.ruta05 = round(rutor.ruta05 + (t.credit - t.debit))
const balance = mapping.side === 'credit' ? t.credit - t.debit : t.debit - t.credit
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
@@ -525,16 +533,16 @@ export async function calculateVatDeclaration(
// försäljning. Resolved from their "Standard moms" rather than a fixed BAS
// list, because Accounted seeds no varugrupp accounts: every 3011/3013-style
// konto is user-added and would otherwise never be fetched at all (#1261).
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
// Fetch and aggregate posted VAT-account activity for the period. The same
// RPC round trip carries the per-source_type entry counts for the metadata.
const { totals, sourceTypeCounts } = await fetchVatAccountTotals(
supabase, companyId, start, end, dynamicRuta05.accounts
supabase, companyId, start, end, dynamicVatAccounts.accounts
)
// Map account balances to momsdeklaration boxes
const rutor = rutorFromTotals(totals, dynamicRuta05.accounts)
const rutor = rutorFromTotals(totals, dynamicVatAccounts)
// Compute per-rate base amounts from individual revenue accounts. The
// company's own accounts carry their rate on the konto itself, so they land
@@ -556,7 +564,7 @@ export async function calculateVatDeclaration(
const t = totals.get(account)
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
for (const [account, rate] of dynamicRuta05.rateByAccount) {
for (const [account, rate] of dynamicVatAccounts.rateByAccount) {
const t = totals.get(account)
if (!t) continue
const bucket = RATE_BUCKET[rate as keyof typeof RATE_BUCKET]
@@ -567,7 +575,7 @@ export async function calculateVatDeclaration(
// gruppkonto) but whose rate only exists as the konto's "Standard moms".
// Rate-only on purpose: their balance is in ruta 05 either way, so adding
// them to dynamicRuta05.accounts would double the filed figure.
for (const [account, rate] of dynamicRuta05.staticRateByAccount) {
for (const [account, rate] of dynamicVatAccounts.staticRateByAccount) {
const t = totals.get(account)
if (!t) continue
const bucket = RATE_BUCKET[rate as keyof typeof RATE_BUCKET]
@@ -597,7 +605,7 @@ export async function calculateVatDeclaration(
rcInputAccountTotals: rcInputTotals(totals),
// Per-momssats RC basis balances (44xx/45xx), the downgrade evidence for
// the per-voucher gap tiering: see VatDeclaration.rcBasisByRate.
rcBasisByRate: rcBasisTotalsByRate(totals),
rcBasisByRate: rcBasisTotalsByRate(totals, dynamicVatAccounts),
invoiceCount,
transactionCount,
breakdown: {
+20 -2
View File
@@ -3,6 +3,7 @@ import type {
VatCheckAccountTotals,
} from './vat-declaration-checks'
import type { VatDeclarationRutor } from '@/types'
import { roundOre } from '@/lib/money'
/**
* The filing gate for the momsdeklaration: ONE derived value that the
@@ -93,6 +94,12 @@ 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
@@ -106,7 +113,10 @@ export interface RcBasisTotalsByRate {
* Debit minus credit, like every basis box: a credit-heavy rate (a period
* dominated by credit notes) legitimately comes out negative.
*/
export function rcBasisTotalsByRate(totals: VatCheckAccountTotals): RcBasisTotalsByRate {
export function rcBasisTotalsByRate(
totals: VatCheckAccountTotals,
dynamic?: { explicitAccounts: Set<string>; rcBasisRateByAccount: Map<string, number> },
): RcBasisTotalsByRate {
const sumGroup = (accounts: readonly string[]): number => {
let sum = 0
for (const account of accounts) {
@@ -115,11 +125,19 @@ export function rcBasisTotalsByRate(totals: VatCheckAccountTotals): RcBasisTotal
}
return Math.round(sum * 100) / 100
}
return {
const result = {
r25: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r25),
r12: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r12),
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'
result[key] = roundOre(result[key] + total.debit - total.credit)
}
return result
}
/**
+90 -185
View File
@@ -1,224 +1,129 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
import {
defaultRateForVatTreatment,
isAccountVatTreatment,
resolveVatTreatmentRuta,
type AccountVatRutaMapping,
} from '@/lib/vat/account-vat-treatment'
/**
* Resolve which of a company's OWN revenue accounts belong in ruta 05
* (momspliktig försäljning som inte ingår i någon annan ruta).
*
* ACCOUNT_RUTA is a fixed BAS whitelist and ruta 05 is literally 3001/3002/3003
* there. That is only correct for a company that never touched its kontoplan:
* the shipped BAS reference (lib/bookkeeping/bas-data/class-3-revenue.ts) has
* no varugrupp accounts at all, so every 3011/3013/3041-style konto is added by
* the user. Their revenue was not merely mapped to the wrong ruta, it was never
* fetched from the ledger: ACCOUNT_RUTA's keys ARE the account filter passed to
* get_vat_declaration_totals. Ruta 05 came out short and, because
* runVatDeclarationChecks compares rutor 05-08 against 10-12, a perfectly
* correct declaration got a blocking OUTPUT_VAT_WITHOUT_SALES error (#1261).
*
* The per-account "Standard moms" (chart_of_accounts.default_vat_rate) is the
* primary resolver: a class 3 konto the user marked 25/12/6 % is by definition
* domestic taxable sales, which is exactly what ruta 05 collects. For a
* missing value, the narrow 30x1/30x2/30x3 convention is accepted only when
* the account label explicitly confirms the same rate. This recovers imported
* and older custom accounts without guessing from a number or free text alone.
*/
/**
* Class 3 accounts that carry a moms-sats but belong in a DIFFERENT ruta, so
* "has a rate" must not be read as "ruta 05". Per the SKV 4700 mapping in
* .claude/skills/swedish-vat/references/vat-compliance-reference.md §7:
*
* 3211/3212/3220 → ruta 07 (vinstmarginalbeskattning)
* 3913 → ruta 08 (hyresinkomster, frivillig skattskyldighet)
*
* Rutor 07 and 08 are not mappable yet (see the note in
* vat-declaration-checks.ts). Until they are, these accounts stay out of the
* declaration entirely: that understates one ruta, whereas sweeping them into
* ruta 05 would file the amount in the wrong box.
*
* 3231/3232/3233 (ruta 41, omvänd skattskyldighet) used to sit here for the
* same reason but are now statically mapped in ACCOUNT_RUTA/ACCOUNT_TO_BOX,
* so the ACCOUNT_TO_BOX guard below excludes them from ruta 05.
*/
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220',
'3913',
])
/** VAT rates that mark a configured konto as momspliktig försäljning. */
const TAXABLE_RATES = [0.25, 0.12, 0.06]
const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = {
'1': 0.25,
'2': 0.12,
'3': 0.06,
}
/**
* Labels that contradict "momspliktig försäljning inom Sverige". Any hit vetoes
* the fallback even when the suffix and a "25/12/6 % moms" label agree: a konto
* named "momsfri", "omvänd betalningsskyldighet", VMB or export belongs in
* ruta 07/08/35/36/41, and inferring a sats for it would file the amount in
* ruta 05, i.e. in the wrong box. Omission is the safe failure here, so a
* contradictory label falls back to today's behaviour instead of guessing.
*
* Only consulted for a MISSING rate. An explicitly configured value stays
* authoritative and never reaches this check.
*
* The word boundaries are load-bearing, not decoration. "0 %" needs a leading
* \b or it also matches the trailing zero of "10/20/30/100 %", vetoing a
* perfectly ordinary "Försäljning varor 25 % moms, rabatt 30 %" and recreating
* the #1261 omission this module exists to remove. "vmb" needs both boundaries
* because three letters occur inside unrelated words. "export" and "utanför"
* are matched as bare substrings on purpose, so Swedish compounds
* ("exportförsäljning") are caught too; both are distinctive enough that a
* false hit would have to be contrived.
*/
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220', '3913',
])
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = { '1': 0.25, '2': 0.12, '3': 0.06 }
const CONTRADICTING_ACCOUNT_NAME =
/momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\b0\s*%/i
/**
* Resolve a missing rate for a company-specific domestic sales sub-account.
*
* Neither signal is sufficient by itself:
* - 3011 is not in the BAS 2026 catalog and custom numbers can be repurposed;
* - account labels are free text and can be stale or contradictory.
*
* Requiring the conventional 30x1/30x2/30x3 suffix and one matching explicit
* "25/12/6 % moms" label keeps the fallback deterministic. A configured value,
* including explicit 0 %, is always authoritative and never reaches here.
*
* Three ways this deliberately answers null:
* - the label states no sats, or states it without the word "moms"
* ("Försäljning konsult 25 %" is a margin or a share, not a moms-sats);
* - the label states two different sats, so neither can be trusted;
* - the label also carries a contradicting term (see above).
*/
function inferDomesticSalesRate(
accountNumber: string,
accountName: string | null | undefined,
): number | null {
function inferDomesticSalesRate(accountNumber: string, accountName: string): number | null {
const accountMatch = /^30\d([123])$/.exec(accountNumber)
if (!accountMatch) return null
const name = accountName ?? ''
if (CONTRADICTING_ACCOUNT_NAME.test(name)) return null
if (!accountMatch || CONTRADICTING_ACCOUNT_NAME.test(accountName)) return null
const expectedRate = DOMESTIC_SALES_RATE_BY_SUFFIX[accountMatch[1]]
const namedRates = new Set(
[...name.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map(
(match) => Number(match[1]) / 100,
)
[...accountName.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map((match) => Number(match[1]) / 100),
)
return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null
}
/**
* Ruta 05 accounts that ACCOUNT_TO_BOX already sums, but whose per-rate bucket
* cannot be inferred from the account number.
*
* 3000 "Försäljning inom Sverige" is the BAS gruppkonto for the 30xx range. A
* BAS-conformant company posts to 3001/3002/3003 and never to 3000, but a
* company that does post to it has genuine domestic taxable sales, so ruta 05
* stays the right box and the filed figure is already correct. What is missing
* is only the rate split: unlike 3001/3002/3003 the number carries no sats, so
* `breakdown.invoices.base25/12/6` would not add up to ruta 05.
*
* These accounts therefore contribute a RATE ONLY. Adding them to `accounts`
* would double-count them, since ACCOUNT_TO_BOX already puts them in the sum.
*/
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
export interface DynamicRuta05Accounts {
/** Accounts to add to the ledger fetch and to the ruta 05 sum. */
export interface DynamicVatAccounts {
accounts: string[]
/** account_number → 0.25 | 0.12 | 0.06, for the per-rate base breakdown. */
mappingByAccount: Map<string, AccountVatRutaMapping>
explicitAccounts: Set<string>
rateByAccount: Map<string, number>
/**
* Rates for accounts ALREADY counted in ruta 05 by the static map. Feeds the
* base breakdown only, never the sum. Empty unless the user set a
* "Standard moms" on one of RUTA_05_STATIC_RATE_ACCOUNTS.
*/
staticRateByAccount: Map<string, number>
rcBasisRateByAccount: Map<string, number>
}
const EMPTY: DynamicRuta05Accounts = {
accounts: [],
rateByAccount: new Map(),
staticRateByAccount: new Map(),
}
const emptyDynamicVatAccounts = (): DynamicVatAccounts => ({
accounts: [], mappingByAccount: new Map(), explicitAccounts: new Set(),
rateByAccount: new Map(), staticRateByAccount: new Map(),
rcBasisRateByAccount: new Map(),
})
/**
* Fetch the company-specific ruta 05 accounts.
*
* Excluded:
* - every account in ACCOUNT_TO_BOX. This covers all of ACCOUNT_RUTA (the
* alignment test in lib/vat/__tests__/moms-box-mapping.test.ts fails if the
* mirror ever stops being a superset) and additionally the accounts only
* the mirror maps (3106, 3109, 3521, 3522). Filtering on the superset alone
* keeps this module off vat-declaration.ts, which imports it.
*
* This exclusion is what makes the BAS backfill safe: it sets 3001 = 25 %,
* and without it 3001 would be counted once by ACCOUNT_RUTA and once here,
* doubling ruta 05.
* - RUTA_05_EXCLUDED_ACCOUNTS above.
*
* The company_id filter is explicit rather than left to RLS:
* calculateVatDeclaration is also reached from /api/v1/* on a service client,
* which has no RLS.
*/
export async function fetchDynamicRuta05Accounts(
/** Explicit treatments extend custom accounts; fixed BAS mappings stay authoritative. */
export async function fetchDynamicVatAccounts(
supabase: SupabaseClient,
companyId: string
): Promise<DynamicRuta05Accounts> {
companyId: string,
): Promise<DynamicVatAccounts> {
const rows = await fetchAllRows<{
account_number: string
account_name: string
account_class: number
default_vat_rate: number | string | null
default_vat_treatment: string | null
}>(
({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, default_vat_rate')
.eq('company_id', companyId)
.eq('account_class', 3)
// Deactivated accounts only. is_active is nullable (boolean DEFAULT
// true, never made NOT NULL) and the accounts API treats only an
// explicit false as deactivated, so `eq(true)` would silently drop a
// NULL-flagged konto: the same kind of quiet omission this whole fix
// exists to remove.
.not('is_active', 'is', false)
.order('account_number', { ascending: true })
.range(from, to)
({ from, to }) => supabase.from('chart_of_accounts')
.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),
)
if (rows.length === 0) return EMPTY
const accounts: string[] = []
const rateByAccount = new Map<string, number>()
const staticRateByAccount = new Map<string, number>()
const result = emptyDynamicVatAccounts()
for (const row of rows) {
const account = row.account_number
const rate = row.default_vat_rate === null
? inferDomesticSalesRate(account, row.account_name)
: Number(row.default_vat_rate)
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
const accountClass = Number(row.account_class ?? account.charAt(0))
const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
// Checked before the ACCOUNT_TO_BOX skip: these accounts ARE in that map,
// which is precisely why they need the rate surfaced separately.
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
staticRateByAccount.set(account, 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)
result.mappingByAccount.set(account, mapping)
result.accounts.push(account)
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass)
if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) {
result.rateByAccount.set(account, rate)
}
if (
['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24'].includes(mapping.box) &&
rate !== null && TAXABLE_RATES.includes(rate)
) {
result.rcBasisRateByAccount.set(account, rate)
}
continue
}
if (ACCOUNT_TO_BOX[account]) continue
if (accountClass !== 3) continue
const rate = configuredRate ?? inferDomesticSalesRate(account, row.account_name)
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
if (ACCOUNT_TO_BOX[account]) {
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
result.staticRateByAccount.set(account, rate)
}
continue
}
if (RUTA_05_EXCLUDED_ACCOUNTS.has(account)) continue
result.accounts.push(account)
result.mappingByAccount.set(account, { box: 'ruta05', side: 'credit' })
result.rateByAccount.set(account, rate)
}
return result
}
accounts.push(account)
rateByAccount.set(account, rate)
export async function fetchDynamicRuta05Accounts(
supabase: SupabaseClient,
companyId: string,
): Promise<Pick<DynamicVatAccounts, 'accounts' | 'rateByAccount' | 'staticRateByAccount'>> {
const resolved = await fetchDynamicVatAccounts(supabase, companyId)
return {
accounts: [...resolved.mappingByAccount]
.filter(([account, mapping]) => mapping.box === 'ruta05' && !ACCOUNT_TO_BOX[account])
.map(([account]) => account),
rateByAccount: resolved.rateByAccount,
staticRateByAccount: resolved.staticRateByAccount,
}
return { accounts, rateByAccount, staticRateByAccount }
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import {
defaultRateForVatTreatment,
isVatTreatmentValidForAccountClass,
resolveVatTreatmentRuta,
suggestVatTreatment,
vatTreatmentsForAccountClass,
} from '../account-vat-treatment'
describe('resolveVatTreatmentRuta', () => {
it('maps revenue treatments to their momsdeklaration boxes', () => {
expect(resolveVatTreatmentRuta('standard_25', 3)).toEqual({ box: 'ruta05', side: 'credit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 3)).toEqual({ box: 'ruta41', side: 'credit' })
expect(resolveVatTreatmentRuta('reverse_charge_eu_goods', 3)).toEqual({ box: 'ruta35', side: 'credit' })
expect(resolveVatTreatmentRuta('reverse_charge_eu_services', 3)).toEqual({ box: 'ruta39', side: 'credit' })
expect(resolveVatTreatmentRuta('export_goods', 3)).toEqual({ box: 'ruta36', side: 'credit' })
expect(resolveVatTreatmentRuta('export_services', 3)).toEqual({ box: 'ruta40', side: 'credit' })
expect(resolveVatTreatmentRuta('exempt', 3)).toEqual({ box: 'ruta42', side: 'credit' })
expect(resolveVatTreatmentRuta('vmb', 3)).toEqual({ box: 'ruta07', side: 'credit' })
expect(resolveVatTreatmentRuta('rental_voluntary', 3)).toEqual({ box: 'ruta08', side: 'credit' })
})
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_domestic', 4)).toEqual({ box: 'ruta23', 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('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({
treatment: 'standard_25', rate: 0.25,
})
expect(suggestVatTreatment('4056', 'Inköp varor 25% EU')).toEqual({
treatment: 'reverse_charge_eu_goods', rate: 0.25,
})
})
it('does not guess from an account number alone', () => {
expect(suggestVatTreatment('3041', 'Projektintäkt')).toBeNull()
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 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 use a gross account rate for VMB', () => {
expect(defaultRateForVatTreatment('vmb', 3)).toBeNull()
expect(suggestVatTreatment('3021', 'Försäljning begagnat 25% VMB')).toEqual({
treatment: 'vmb',
rate: null,
})
})
})
+121
View File
@@ -0,0 +1,121 @@
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',
] as const
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
export interface AccountVatRutaMapping {
box: keyof VatDeclarationRutor
side: 'credit' | 'debit'
}
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',
export_goods: 'ruta36', export_services: 'ruta40', vmb: 'ruta07',
rental_voluntary: 'ruta08',
}
export function resolveVatTreatmentRuta(
treatment: AccountVatTreatment,
accountClass: number,
): AccountVatRutaMapping | null {
if (accountClass === 3) {
const box = REVENUE_RUTA[treatment]
return box ? { box, side: 'credit' } : null
}
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_domestic') {
return { box: accountClass === 4 ? 'ruta23' : 'ruta24', side: 'debit' }
}
if (treatment === 'export_goods') return { box: 'ruta50', side: 'debit' }
return null
}
export function defaultRateForVatTreatment(
treatment: AccountVatTreatment,
accountClass: number,
): number | null {
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
}
export function isAccountVatTreatment(value: unknown): value is AccountVatTreatment {
return typeof value === 'string' &&
(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
}
/**
* Suggest a VAT treatment from a SIE account label. SIE #SRU and #KTYP are
* deliberately excluded: neither record carries a momsdeklaration treatment.
* Suggestions are persisted only after the user reviews the import mapping.
*/
export function suggestVatTreatment(
accountNumber: string,
accountName: string,
): SuggestedVatTreatment | null {
const accountClass = Number(accountNumber.charAt(0))
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
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 (/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 (/försälj|forsalj|intäkt|intakt/.test(name) && percent) {
return {
treatment: rate === 0.12 ? 'reduced_12' : rate === 0.06 ? 'reduced_6' : 'standard_25',
rate,
}
}
return null
}
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 (/\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
}
+24 -1
View File
@@ -4919,7 +4919,27 @@
"toast_pruned_with_skipped": "{deleted} accounts deleted, {skipped} skipped",
"toast_prune_failed": "Could not clean up the chart of accounts",
"bas_version_chip": "BAS 2026",
"footer_note": "Showing {shown} of {total} accounts, grouped by BAS class. SRU codes carry through to the income tax return."
"footer_note": "Showing {shown} of {total} accounts, grouped by BAS class. SRU codes carry through to the income tax return.",
"vat_treatment_label": "VAT treatment for the VAT return",
"vat_treatment_none": "Use BAS default",
"vat_treatment_help": "Controls which VAT return box receives the account balance. A selected treatment overrides the BAS default.",
"vat_treatment_not_applicable": "VAT treatment is available for revenue and purchase accounts in classes 3-6.",
"vat_treatment_standard_25": "Swedish sales, 25% (box 05)",
"vat_treatment_reduced_12": "Swedish sales, 12% (box 05)",
"vat_treatment_reduced_6": "Swedish sales, 6% (box 05)",
"vat_treatment_exempt": "VAT-exempt sales (box 42)",
"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_export_services": "Services outside the EU",
"vat_treatment_vmb": "Margin scheme (box 07)",
"vat_treatment_rental_voluntary": "Voluntary VAT on rental income (box 08)",
"vat_rate_label": "VAT rate",
"vat_rate_none": "None",
"vat_review_filter": "{count} VAT treatments to review",
"vat_treatment_column": "VAT treatment",
"vat_treatment_confirm": "Confirm"
},
"dimensions": {
"new_value": "New value",
@@ -6854,6 +6874,9 @@
"reveal_bridge": "The history is in place. What's missing is the present: the bank.",
"reveal_cta_bank": "Connect the bank",
"reveal_cta_open": "Open Accounted",
"vat_review_title": "{count, plural, one {# account has no VAT treatment} other {# accounts have no VAT treatment}}",
"vat_review_description": "Set VAT treatments on imported revenue and purchase accounts so the VAT return uses the correct boxes.",
"vat_review_action": "Review VAT treatments in the chart",
"export_title": "Export",
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
"tab_import": "Import",
+24 -1
View File
@@ -4919,7 +4919,27 @@
"toast_pruned_with_skipped": "{deleted} konton togs bort, {skipped} hoppades över",
"toast_prune_failed": "Kunde inte rensa kontoplanen",
"bas_version_chip": "BAS 2026",
"footer_note": "Visar {shown} av {total} konton, grupperade per BAS-klass. SRU-koderna följer med till inkomstdeklarationen."
"footer_note": "Visar {shown} av {total} konton, grupperade per BAS-klass. SRU-koderna följer med till inkomstdeklarationen.",
"vat_treatment_label": "Momskod för momsdeklarationen",
"vat_treatment_none": "Använd BAS-standard",
"vat_treatment_help": "Styr vilken ruta kontots belopp hamnar i. En vald momskod går före BAS-standarden.",
"vat_treatment_not_applicable": "Momskod kan väljas för intäkts- och inköpskonton i klass 3-6.",
"vat_treatment_standard_25": "Försäljning Sverige, 25 % (ruta 05)",
"vat_treatment_reduced_12": "Försäljning Sverige, 12 % (ruta 05)",
"vat_treatment_reduced_6": "Försäljning Sverige, 6 % (ruta 05)",
"vat_treatment_exempt": "Momsfri försäljning (ruta 42)",
"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_export_services": "Tjänster utanför EU",
"vat_treatment_vmb": "Vinstmarginalbeskattning (ruta 07)",
"vat_treatment_rental_voluntary": "Frivillig moms på uthyrning (ruta 08)",
"vat_rate_label": "Momssats",
"vat_rate_none": "Ingen",
"vat_review_filter": "{count} momskoder att granska",
"vat_treatment_column": "Momskod",
"vat_treatment_confirm": "Bekräfta"
},
"dimensions": {
"new_value": "Nytt värde",
@@ -6854,6 +6874,9 @@
"reveal_bridge": "Historiken är på plats. Det som saknas är nuet: banken.",
"reveal_cta_bank": "Koppla banken",
"reveal_cta_open": "Öppna Accounted",
"vat_review_title": "{count, plural, one {# konto saknar momskod} other {# konton saknar momskod}}",
"vat_review_description": "Sätt momskod på importerade intäkts- och inköpskonton så att momsdeklarationen hamnar i rätt ruta.",
"vat_review_action": "Granska momskoder i kontoplanen",
"export_title": "Exportera",
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
"tab_import": "Importera",
@@ -0,0 +1,19 @@
ALTER TABLE public.chart_of_accounts
ADD COLUMN IF NOT EXISTS default_vat_treatment text;
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 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'
));
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';
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
async function setTreatment(companyId: 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],
)
}
async function insertAccount(companyId: string, userId: string) {
return 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],
)
}
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 = [
'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 })
}
})
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()
})
})
+1
View File
@@ -1734,6 +1734,7 @@ export interface BASAccount {
// Per-account default VAT rate for booking lines (0/0.06/0.12/0.25).
// null = no default (line keeps its own rate). Öresavrundning (3740) = 0.
default_vat_rate: number | null
default_vat_treatment: import('@/lib/vat/account-vat-treatment').AccountVatTreatment | null
description: string | null
sru_code: string | null
k2_excluded: boolean