@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user