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

Closes #1457
This commit is contained in:
Mattsson
2026-08-13 17:03:35 +02:00
committed by GitHub
parent 7881f757a9
commit d02fd82191
32 changed files with 1196 additions and 253 deletions
+25
View File
@@ -1996,6 +1996,23 @@ describe('CreateAccountSchema', () => {
})
expect(result.success).toBe(false)
})
it('rejects a VAT treatment that does not apply to the account class', () => {
expect(CreateAccountSchema.safeParse({
account_number: '5010',
account_name: 'Consulting costs',
account_type: 'expense',
normal_balance: 'debit',
default_vat_treatment: 'standard_25',
}).success).toBe(false)
expect(CreateAccountSchema.safeParse({
account_number: '5010',
account_name: 'EU services',
account_type: 'expense',
normal_balance: 'debit',
default_vat_treatment: 'reverse_charge_eu_services',
}).success).toBe(true)
})
})
// ============================================================
@@ -2305,6 +2322,14 @@ describe('UpdateAccountSchema', () => {
default_vat_rate: 0.5,
}).success).toBe(false)
})
it('accepts supported account VAT treatments and rejects unknown values', () => {
expect(UpdateAccountSchema.safeParse({
default_vat_treatment: 'reverse_charge_eu_goods',
}).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: null }).success).toBe(true)
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: 'eu_purchase' }).success).toBe(false)
})
})
// ============================================================
+26
View File
@@ -16,6 +16,7 @@ import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { isVatTreatmentValidForAccountClass } from '@/lib/vat/account-vat-treatment'
// ============================================================
// Shared primitives
@@ -2110,6 +2111,15 @@ const defaultVatRate = z
.nullable()
.optional()
export const AccountVatTreatmentSchema = z.enum([
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'export_goods', 'export_services',
'vmb', 'rental_voluntary',
])
const defaultVatTreatment = AccountVatTreatmentSchema.nullable().optional()
export const CreateAccountSchema = z.object({
account_number: accountNumber,
account_name: z.string().min(1, 'Account name is required'),
@@ -2119,7 +2129,22 @@ export const CreateAccountSchema = z.object({
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
default_vat_rate: defaultVatRate,
default_vat_treatment: defaultVatTreatment,
sru_code: z.string().nullable().optional(),
}).superRefine((value, ctx) => {
if (
value.default_vat_treatment &&
!isVatTreatmentValidForAccountClass(
value.default_vat_treatment,
Number(value.account_number.charAt(0)),
)
) {
ctx.addIssue({
code: 'custom',
path: ['default_vat_treatment'],
message: 'VAT treatment is not valid for the account class',
})
}
})
export const UpdateAccountSchema = z.object({
@@ -2128,6 +2153,7 @@ export const UpdateAccountSchema = z.object({
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
default_vat_rate: defaultVatRate,
default_vat_treatment: defaultVatTreatment,
sru_code: z.string().nullable().optional(),
})
+19
View File
@@ -102,6 +102,25 @@ function run(
// --- Tests ---
describe('syncMappedAccounts: create pass', () => {
it('persists a reviewed VAT treatment on a created identity account', async () => {
const { supabase, inserts } = buildCapturingSupabase()
const result = await run(supabase, [
mapping({
sourceAccount: '4056',
targetAccount: '4056',
sourceName: 'Inköp varor 25% EU',
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: 0.25,
vatTreatmentReviewed: true,
}),
])
expect(result.error).toBeNull()
expect(inserts[0]).toMatchObject({
default_vat_treatment: 'reverse_charge_eu_goods',
default_vat_rate: 0.25,
})
})
it('creates a missing BAS account with the BAS default name when the file has no custom name', async () => {
const { supabase, inserts } = buildCapturingSupabase()
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
applyVatTreatmentReview,
enrichAccountMappingsWithVat,
} from '../account-vat-treatment'
import type { AccountMapping } from '../types'
function mapping(account: string, name: string): AccountMapping {
return {
sourceAccount: account,
sourceName: name,
targetAccount: account,
targetName: name,
confidence: 1,
matchType: 'exact',
isOverride: false,
}
}
describe('enrichAccountMappingsWithVat', () => {
it('marks label suggestions for user review', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('4056', 'Inköp varor 25% EU')],
[],
)
expect(result).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_goods',
defaultVatRate: 0.25,
vatTreatmentSuggested: true,
vatTreatmentReviewed: false,
requiresVatTreatmentReview: true,
})
})
it('requires review for suggested class 5 and 6 purchase treatments', () => {
const results = enrichAccountMappingsWithVat(
[
mapping('5010', 'Inköp tjänst EU'),
mapping('6010', 'Inköp tjänst utanför EU'),
],
[],
)
expect(results).toEqual([
expect.objectContaining({
defaultVatTreatment: 'reverse_charge_eu_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
expect.objectContaining({
defaultVatTreatment: 'export_services',
requiresVatTreatmentReview: true,
vatTreatmentReviewed: false,
}),
])
})
it('keeps an existing account treatment without asking again', () => {
const [result] = enrichAccountMappingsWithVat(
[mapping('3041', 'Försäljning tjänst 25% sv')],
[{
account_number: '3041',
default_vat_treatment: 'standard_25',
default_vat_rate: 0.25,
} as never],
)
expect(result).toMatchObject({
defaultVatTreatment: 'standard_25',
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
requiresVatTreatmentReview: false,
})
})
})
describe('applyVatTreatmentReview', () => {
it('marks only the selected row reviewed, including class 5 and 6 accounts', () => {
const mappings = [
mapping('5010', 'Inköp tjänst EU'),
mapping('3041', 'Försäljning tjänst 25% sv'),
]
const result = applyVatTreatmentReview(
mappings,
'5010',
'reverse_charge_eu_services',
0.25,
)
expect(result[0]).toMatchObject({
defaultVatTreatment: 'reverse_charge_eu_services',
defaultVatRate: 0.25,
vatTreatmentReviewed: true,
})
expect(result[1].vatTreatmentReviewed).toBeUndefined()
})
})
+45 -1
View File
@@ -14,6 +14,7 @@ import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { AccountMapping } from './types'
import { isAccountVatTreatment, type AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
export interface AccountSyncResult {
/** Accounts inserted into chart_of_accounts */
@@ -43,6 +44,7 @@ function buildInsertRow(
basRef: BASReferenceAccount | undefined,
companyId: string,
userId: string,
vatDefaults?: { treatment: AccountVatTreatment | null; rate: number | null },
) {
const sortOrder = /^\d+$/.test(accountNumber) ? parseInt(accountNumber, 10) : null
@@ -63,6 +65,8 @@ function buildInsertRow(
is_system_account: false,
description: basRef.description,
sort_order: sortOrder,
default_vat_treatment: vatDefaults?.treatment ?? null,
default_vat_rate: vatDefaults?.rate ?? null,
}
}
@@ -84,6 +88,8 @@ function buildInsertRow(
is_system_account: false,
description: accountName,
sort_order: sortOrder,
default_vat_treatment: vatDefaults?.treatment ?? null,
default_vat_rate: vatDefaults?.rate ?? null,
}
}
@@ -143,6 +149,27 @@ export async function syncMappedAccounts(
if (m.targetAccount && fallback) fallbackNames.set(m.targetAccount, fallback)
}
const vatDefaults = new Map<string, { treatment: AccountVatTreatment | null; rate: number | null }>()
for (const mapping of mappings) {
if (
!mapping.vatTreatmentReviewed ||
!mapping.targetAccount ||
mapping.sourceAccount !== mapping.targetAccount
) continue
if (
mapping.defaultVatTreatment !== null &&
mapping.defaultVatTreatment !== undefined &&
!isAccountVatTreatment(mapping.defaultVatTreatment)
) {
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
return result
}
vatDefaults.set(mapping.targetAccount, {
treatment: mapping.defaultVatTreatment ?? null,
rate: mapping.defaultVatRate ?? null,
})
}
// Fetch the company's chart once (paged) and filter in JS: avoids a huge
// .in() URL for full-chart imports and the silent 1000-row PostgREST cap.
let existingByNumber: Map<string, string>
@@ -181,7 +208,7 @@ export async function syncMappedAccounts(
basRef?.account_name ??
fallbackNames.get(num) ??
`Konto ${num}`
return buildInsertRow(num, name, basRef, companyId, userId)
return buildInsertRow(num, name, basRef, companyId, userId, vatDefaults.get(num))
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
@@ -195,6 +222,23 @@ export async function syncMappedAccounts(
result.created = missing.length
}
const existingVatUpdates = [...vatDefaults]
.filter(([account]) => existingByNumber.has(account))
for (const [account, defaults] of existingVatUpdates) {
const { error: vatUpdateError } = await supabase
.from('chart_of_accounts')
.update({
default_vat_treatment: defaults.treatment,
default_vat_rate: defaults.rate,
})
.eq('company_id', companyId)
.eq('account_number', account)
if (vatUpdateError) {
result.error = vatUpdateError.message
return result
}
}
// Rename pass: carry the file's names into existing accounts. The diff set
// is small (only names that actually changed), so the UPDATEs run
// concurrently in bounded batches: a full-chart re-sync must not serialize
+67
View File
@@ -0,0 +1,67 @@
import type { BASAccount } from '@/types'
import {
suggestVatTreatment,
type AccountVatTreatment,
} from '@/lib/vat/account-vat-treatment'
import type { AccountMapping } from './types'
/**
* Add reviewable VAT suggestions to identity mappings. SIE itself has no VAT
* treatment record, so suggestions come only from the account label and are
* never considered reviewed until the user continues from the mapping step.
*/
export function enrichAccountMappingsWithVat(
mappings: AccountMapping[],
existingAccounts: BASAccount[],
): AccountMapping[] {
const existingByNumber = new Map(
existingAccounts.map((account) => [account.account_number, account]),
)
return mappings.map((mapping) => {
if (!mapping.targetAccount || mapping.sourceAccount !== mapping.targetAccount) return mapping
const accountClass = Number(mapping.sourceAccount.charAt(0))
if (accountClass < 3 || accountClass > 6) return mapping
const existing = existingByNumber.get(mapping.targetAccount)
if (existing?.default_vat_treatment) {
return {
...mapping,
defaultVatTreatment: existing.default_vat_treatment,
defaultVatRate: existing.default_vat_rate,
vatTreatmentReviewed: true,
vatTreatmentSuggested: false,
requiresVatTreatmentReview: false,
}
}
const suggestion = suggestVatTreatment(mapping.sourceAccount, mapping.sourceName)
return {
...mapping,
defaultVatTreatment: suggestion?.treatment ?? null,
defaultVatRate: suggestion?.rate ?? existing?.default_vat_rate ?? null,
vatTreatmentSuggested: Boolean(suggestion),
vatTreatmentReviewed: false,
requiresVatTreatmentReview: accountClass >= 3 && accountClass <= 6,
}
})
}
export function applyVatTreatmentReview(
mappings: AccountMapping[],
sourceAccount: string,
treatment: AccountVatTreatment | null,
rate: number | null,
): AccountMapping[] {
return mappings.map((mapping) =>
mapping.sourceAccount === sourceAccount
? {
...mapping,
defaultVatTreatment: treatment,
defaultVatRate: rate,
vatTreatmentSuggested: false,
vatTreatmentReviewed: true,
}
: mapping
)
}
+5
View File
@@ -185,6 +185,11 @@ export interface AccountMapping {
confidence: number // 0-1
matchType: AccountMatchType
isOverride: boolean // User manually set this
defaultVatTreatment?: import('@/lib/vat/account-vat-treatment').AccountVatTreatment | null
defaultVatRate?: number | null
vatTreatmentSuggested?: boolean
vatTreatmentReviewed?: boolean
requiresVatTreatmentReview?: boolean
}
/**
@@ -21,6 +21,11 @@ vi.mock('@/lib/bookkeeping/entry-lines', () => ({
fetchLinesByEntryIds: (...args: unknown[]) => fetchLinesByEntryIdsMock(...args),
}))
const fetchDynamicVatAccountsMock = vi.fn()
vi.mock('../vat-revenue-accounts', () => ({
fetchDynamicVatAccounts: (...args: unknown[]) => fetchDynamicVatAccountsMock(...args),
}))
import { findRcBasisGaps } from '../rc-basis-gaps'
const supabase = {} as SupabaseClient
@@ -47,6 +52,10 @@ describe('findRcBasisGaps', () => {
resolvePeriodDatesMock.mockResolvedValue({ start: '2025-07-17', end: '2026-12-31' })
fetchEntryLinesMock.mockResolvedValue([])
fetchLinesByEntryIdsMock.mockResolvedValue([])
fetchDynamicVatAccountsMock.mockResolvedValue({
explicitAccounts: new Set(),
rcBasisRateByAccount: new Map(),
})
})
it('resolves the period via resolvePeriodDates with the fiscal period id', async () => {
@@ -124,4 +133,32 @@ describe('findRcBasisGaps', () => {
expect(gaps).toHaveLength(1)
expect(gaps[0].expectedBasisAmount).toBe(10000)
})
it('accepts a custom EU purchase basis account at the matching rate', async () => {
fetchDynamicVatAccountsMock.mockResolvedValue({
explicitAccounts: new Set(['4056']),
rcBasisRateByAccount: new Map([['4056', 0.25]]),
})
fetchEntryLinesMock.mockResolvedValue([rcLine('entry-1', 8, 250)])
fetchLinesByEntryIdsMock.mockResolvedValue([{
id: 'l-1', journal_entry_id: 'entry-1', account_number: '4056',
debit_amount: 1000, credit_amount: 0,
}])
await expect(findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5))
.resolves.toEqual([])
})
it('does not let a 25 percent basis cover 12 percent output VAT', async () => {
fetchEntryLinesMock.mockResolvedValue([{
...rcLine('entry-1', 8, 120),
account_number: '2624',
}])
fetchLinesByEntryIdsMock.mockResolvedValue([{
id: 'l-1', journal_entry_id: 'entry-1', account_number: '4535',
debit_amount: 1000, credit_amount: 0,
}])
const gaps = await findRcBasisGaps(supabase, 'company-1', 'monthly', 2026, 5)
expect(gaps).toHaveLength(1)
expect(gaps[0].rate).toBe(0.12)
})
})
+39 -2
View File
@@ -16,7 +16,9 @@ let results: Array<{ data?: unknown; error?: unknown }>
let chartAccounts: Array<{
account_number: string
account_name?: string
account_class?: number
default_vat_rate: number | null
default_vat_treatment?: string | null
}>
function makeBuilder() {
@@ -37,12 +39,17 @@ function makeBuilder() {
*/
function makeChartBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'not', 'order', 'range']) {
for (const m of ['select', 'eq', 'gte', 'lte', 'in', 'not', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.then = (resolve: (v: unknown) => void) =>
resolve({
data: chartAccounts.map((account) => ({ account_name: '', ...account })),
data: chartAccounts.map((account) => ({
account_name: '',
account_class: 3,
default_vat_treatment: null,
...account,
})),
error: null,
})
return b
@@ -117,6 +124,36 @@ beforeEach(() => {
// Pure function tests: no mocks needed
// ============================================================
describe('rutorFromTotals: explicit account VAT treatments', () => {
it('puts a custom sales account in ruta 05', () => {
const totals = new Map([['3041', { debit: 0, credit: 1000 }]])
const rutor = rutorFromTotals(totals, {
mappingByAccount: new Map([['3041', { box: 'ruta05', side: 'credit' }]]),
explicitAccounts: new Set(['3041']),
})
expect(rutor.ruta05).toBe(1000)
})
it('puts a custom EU purchase account in ruta 20', () => {
const totals = new Map([['4056', { debit: 1000, credit: 0 }]])
const rutor = rutorFromTotals(totals, {
mappingByAccount: new Map([['4056', { box: 'ruta20', side: 'debit' }]]),
explicitAccounts: new Set(['4056']),
})
expect(rutor.ruta20).toBe(1000)
})
it('keeps a static BAS mapping authoritative over an explicit treatment', () => {
const totals = new Map([['3001', { debit: 0, credit: 1000 }]])
const rutor = rutorFromTotals(totals, {
mappingByAccount: new Map([['3001', { box: 'ruta42', side: 'credit' }]]),
explicitAccounts: new Set(['3001']),
})
expect(rutor.ruta05).toBe(1000)
expect(rutor.ruta42).toBe(0)
})
})
describe('rutorFromTotals: ruta 41 (omvänd skattskyldighet, sales side)', () => {
it('projects 3231/3232/3233 credit balances into ruta 41', () => {
const totals = new Map([
+15 -10
View File
@@ -6,6 +6,7 @@ import {
} from '@/lib/bookkeeping/entry-lines'
import { resolvePeriodDates } from './vat-declaration'
import { RC_BASIS_ACCOUNTS_BY_RATE } from './vat-filing-gate'
import { fetchDynamicVatAccounts } from './vat-revenue-accounts'
import type { VatPeriodType } from '@/types'
/**
@@ -24,10 +25,10 @@ type RcOutputAccount = typeof RC_OUTPUT_ACCOUNTS[number]
// domestic goods RC (4415-4417), domestic services RC (4425-4427). Derived
// from the rate-grouped single source in vat-filing-gate.ts so this scan and
// the per-rate downgrade evidence can never disagree on the account set.
const RC_BASIS_ACCOUNTS = new Set<string>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
const STATIC_RC_BASIS_RATE = new Map<string, number>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25.map((account) => [account, 0.25] as const),
...RC_BASIS_ACCOUNTS_BY_RATE.r12.map((account) => [account, 0.12] as const),
...RC_BASIS_ACCOUNTS_BY_RATE.r6.map((account) => [account, 0.06] as const),
])
const RATE_BY_OUTPUT: Record<RcOutputAccount, number> = {
@@ -119,6 +120,7 @@ export async function findRcBasisGaps(
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
const rcLines = (await fetchEntryLines<unknown>({
@@ -146,12 +148,15 @@ export async function findRcBasisGaps(
'id, journal_entry_id, account_number, debit_amount, credit_amount',
)
const basisByEntry = new Map<string, number>()
const basisByEntryAndRate = new Map<string, number>()
for (const line of siblingLines) {
if (RC_BASIS_ACCOUNTS.has(line.account_number)) {
const prev = basisByEntry.get(line.journal_entry_id) || 0
basisByEntry.set(
line.journal_entry_id,
const rate = STATIC_RC_BASIS_RATE.get(line.account_number) ??
dynamicVatAccounts.rcBasisRateByAccount.get(line.account_number)
if (rate) {
const key = `${line.journal_entry_id}:${rate}`
const prev = basisByEntryAndRate.get(key) || 0
basisByEntryAndRate.set(
key,
prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
)
}
@@ -179,7 +184,7 @@ export async function findRcBasisGaps(
const rate = RATE_BY_OUTPUT[account]
if (!rate) continue
const expectedBasis = Math.round((amount / rate) * 100) / 100
const actualBasis = basisByEntry.get(row.journal_entry_id) || 0
const actualBasis = basisByEntryAndRate.get(`${row.journal_entry_id}:${rate}`) || 0
if (actualBasis + eps >= expectedBasis) continue
const entry = pickEntry(row)
+20 -12
View File
@@ -6,7 +6,7 @@ import type {
} from '@/types'
import type { VatCheckAccountTotals } from './vat-declaration-checks'
import { rcBasisTotalsByRate } from './vat-filing-gate'
import { fetchDynamicRuta05Accounts } from './vat-revenue-accounts'
import { fetchDynamicVatAccounts, type DynamicVatAccounts } from './vat-revenue-accounts'
/**
* Calculate VAT declaration (Momsdeklaration) for a given period.
@@ -414,8 +414,16 @@ export async function fetchVatAccountTotals(
*/
export function rutorFromTotals(
totals: Map<string, { debit: number; credit: number }>,
dynamicRuta05Accounts: string[] = []
dynamicVatAccounts?: Pick<DynamicVatAccounts, 'mappingByAccount' | 'explicitAccounts'> | string[],
): VatDeclarationRutor {
const dynamic = Array.isArray(dynamicVatAccounts)
? {
mappingByAccount: new Map(dynamicVatAccounts.map((account) => [
account, { box: 'ruta05' as const, side: 'credit' as const },
])),
explicitAccounts: new Set<string>(),
}
: dynamicVatAccounts
const rutor: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
@@ -436,12 +444,12 @@ export function rutorFromTotals(
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// The company's own momspliktiga intäktskonton. Always credit-side: these are
// revenue accounts by construction (account_class 3).
for (const account of dynamicRuta05Accounts) {
for (const [account, mapping] of dynamic?.mappingByAccount ?? []) {
if (ACCOUNT_RUTA[account]) continue
const t = totals.get(account)
if (!t) continue
rutor.ruta05 = round(rutor.ruta05 + (t.credit - t.debit))
const balance = mapping.side === 'credit' ? t.credit - t.debit : t.debit - t.credit
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
@@ -525,16 +533,16 @@ export async function calculateVatDeclaration(
// försäljning. Resolved from their "Standard moms" rather than a fixed BAS
// list, because Accounted seeds no varugrupp accounts: every 3011/3013-style
// konto is user-added and would otherwise never be fetched at all (#1261).
const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId)
const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId)
// Fetch and aggregate posted VAT-account activity for the period. The same
// RPC round trip carries the per-source_type entry counts for the metadata.
const { totals, sourceTypeCounts } = await fetchVatAccountTotals(
supabase, companyId, start, end, dynamicRuta05.accounts
supabase, companyId, start, end, dynamicVatAccounts.accounts
)
// Map account balances to momsdeklaration boxes
const rutor = rutorFromTotals(totals, dynamicRuta05.accounts)
const rutor = rutorFromTotals(totals, dynamicVatAccounts)
// Compute per-rate base amounts from individual revenue accounts. The
// company's own accounts carry their rate on the konto itself, so they land
@@ -556,7 +564,7 @@ export async function calculateVatDeclaration(
const t = totals.get(account)
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
for (const [account, rate] of dynamicRuta05.rateByAccount) {
for (const [account, rate] of dynamicVatAccounts.rateByAccount) {
const t = totals.get(account)
if (!t) continue
const bucket = RATE_BUCKET[rate as keyof typeof RATE_BUCKET]
@@ -567,7 +575,7 @@ export async function calculateVatDeclaration(
// gruppkonto) but whose rate only exists as the konto's "Standard moms".
// Rate-only on purpose: their balance is in ruta 05 either way, so adding
// them to dynamicRuta05.accounts would double the filed figure.
for (const [account, rate] of dynamicRuta05.staticRateByAccount) {
for (const [account, rate] of dynamicVatAccounts.staticRateByAccount) {
const t = totals.get(account)
if (!t) continue
const bucket = RATE_BUCKET[rate as keyof typeof RATE_BUCKET]
@@ -597,7 +605,7 @@ export async function calculateVatDeclaration(
rcInputAccountTotals: rcInputTotals(totals),
// Per-momssats RC basis balances (44xx/45xx), the downgrade evidence for
// the per-voucher gap tiering: see VatDeclaration.rcBasisByRate.
rcBasisByRate: rcBasisTotalsByRate(totals),
rcBasisByRate: rcBasisTotalsByRate(totals, dynamicVatAccounts),
invoiceCount,
transactionCount,
breakdown: {
+20 -2
View File
@@ -3,6 +3,7 @@ import type {
VatCheckAccountTotals,
} from './vat-declaration-checks'
import type { VatDeclarationRutor } from '@/types'
import { roundOre } from '@/lib/money'
/**
* The filing gate for the momsdeklaration: ONE derived value that the
@@ -93,6 +94,12 @@ export const RC_BASIS_ACCOUNTS_BY_RATE = {
r6: ['4517', '4537', '4533', '4417', '4427'],
} as const
const STATIC_RC_BASIS_ACCOUNTS = new Set<string>([
...RC_BASIS_ACCOUNTS_BY_RATE.r25,
...RC_BASIS_ACCOUNTS_BY_RATE.r12,
...RC_BASIS_ACCOUNTS_BY_RATE.r6,
])
/** Net debit balance of the RC basis accounts, one figure per momssats. */
export interface RcBasisTotalsByRate {
r25: number
@@ -106,7 +113,10 @@ export interface RcBasisTotalsByRate {
* Debit minus credit, like every basis box: a credit-heavy rate (a period
* dominated by credit notes) legitimately comes out negative.
*/
export function rcBasisTotalsByRate(totals: VatCheckAccountTotals): RcBasisTotalsByRate {
export function rcBasisTotalsByRate(
totals: VatCheckAccountTotals,
dynamic?: { explicitAccounts: Set<string>; rcBasisRateByAccount: Map<string, number> },
): RcBasisTotalsByRate {
const sumGroup = (accounts: readonly string[]): number => {
let sum = 0
for (const account of accounts) {
@@ -115,11 +125,19 @@ export function rcBasisTotalsByRate(totals: VatCheckAccountTotals): RcBasisTotal
}
return Math.round(sum * 100) / 100
}
return {
const result = {
r25: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r25),
r12: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r12),
r6: sumGroup(RC_BASIS_ACCOUNTS_BY_RATE.r6),
}
for (const [account, rate] of dynamic?.rcBasisRateByAccount ?? []) {
if (STATIC_RC_BASIS_ACCOUNTS.has(account)) continue
const total = totals.get(account)
if (!total) continue
const key = rate === 0.25 ? 'r25' : rate === 0.12 ? 'r12' : 'r6'
result[key] = roundOre(result[key] + total.debit - total.credit)
}
return result
}
/**
+90 -185
View File
@@ -1,224 +1,129 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
import {
defaultRateForVatTreatment,
isAccountVatTreatment,
resolveVatTreatmentRuta,
type AccountVatRutaMapping,
} from '@/lib/vat/account-vat-treatment'
/**
* Resolve which of a company's OWN revenue accounts belong in ruta 05
* (momspliktig försäljning som inte ingår i någon annan ruta).
*
* ACCOUNT_RUTA is a fixed BAS whitelist and ruta 05 is literally 3001/3002/3003
* there. That is only correct for a company that never touched its kontoplan:
* the shipped BAS reference (lib/bookkeeping/bas-data/class-3-revenue.ts) has
* no varugrupp accounts at all, so every 3011/3013/3041-style konto is added by
* the user. Their revenue was not merely mapped to the wrong ruta, it was never
* fetched from the ledger: ACCOUNT_RUTA's keys ARE the account filter passed to
* get_vat_declaration_totals. Ruta 05 came out short and, because
* runVatDeclarationChecks compares rutor 05-08 against 10-12, a perfectly
* correct declaration got a blocking OUTPUT_VAT_WITHOUT_SALES error (#1261).
*
* The per-account "Standard moms" (chart_of_accounts.default_vat_rate) is the
* primary resolver: a class 3 konto the user marked 25/12/6 % is by definition
* domestic taxable sales, which is exactly what ruta 05 collects. For a
* missing value, the narrow 30x1/30x2/30x3 convention is accepted only when
* the account label explicitly confirms the same rate. This recovers imported
* and older custom accounts without guessing from a number or free text alone.
*/
/**
* Class 3 accounts that carry a moms-sats but belong in a DIFFERENT ruta, so
* "has a rate" must not be read as "ruta 05". Per the SKV 4700 mapping in
* .claude/skills/swedish-vat/references/vat-compliance-reference.md §7:
*
* 3211/3212/3220 → ruta 07 (vinstmarginalbeskattning)
* 3913 → ruta 08 (hyresinkomster, frivillig skattskyldighet)
*
* Rutor 07 and 08 are not mappable yet (see the note in
* vat-declaration-checks.ts). Until they are, these accounts stay out of the
* declaration entirely: that understates one ruta, whereas sweeping them into
* ruta 05 would file the amount in the wrong box.
*
* 3231/3232/3233 (ruta 41, omvänd skattskyldighet) used to sit here for the
* same reason but are now statically mapped in ACCOUNT_RUTA/ACCOUNT_TO_BOX,
* so the ACCOUNT_TO_BOX guard below excludes them from ruta 05.
*/
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220',
'3913',
])
/** VAT rates that mark a configured konto as momspliktig försäljning. */
const TAXABLE_RATES = [0.25, 0.12, 0.06]
const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = {
'1': 0.25,
'2': 0.12,
'3': 0.06,
}
/**
* Labels that contradict "momspliktig försäljning inom Sverige". Any hit vetoes
* the fallback even when the suffix and a "25/12/6 % moms" label agree: a konto
* named "momsfri", "omvänd betalningsskyldighet", VMB or export belongs in
* ruta 07/08/35/36/41, and inferring a sats for it would file the amount in
* ruta 05, i.e. in the wrong box. Omission is the safe failure here, so a
* contradictory label falls back to today's behaviour instead of guessing.
*
* Only consulted for a MISSING rate. An explicitly configured value stays
* authoritative and never reaches this check.
*
* The word boundaries are load-bearing, not decoration. "0 %" needs a leading
* \b or it also matches the trailing zero of "10/20/30/100 %", vetoing a
* perfectly ordinary "Försäljning varor 25 % moms, rabatt 30 %" and recreating
* the #1261 omission this module exists to remove. "vmb" needs both boundaries
* because three letters occur inside unrelated words. "export" and "utanför"
* are matched as bare substrings on purpose, so Swedish compounds
* ("exportförsäljning") are caught too; both are distinctive enough that a
* false hit would have to be contrived.
*/
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3211', '3212', '3220', '3913',
])
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = { '1': 0.25, '2': 0.12, '3': 0.06 }
const CONTRADICTING_ACCOUNT_NAME =
/momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\b0\s*%/i
/**
* Resolve a missing rate for a company-specific domestic sales sub-account.
*
* Neither signal is sufficient by itself:
* - 3011 is not in the BAS 2026 catalog and custom numbers can be repurposed;
* - account labels are free text and can be stale or contradictory.
*
* Requiring the conventional 30x1/30x2/30x3 suffix and one matching explicit
* "25/12/6 % moms" label keeps the fallback deterministic. A configured value,
* including explicit 0 %, is always authoritative and never reaches here.
*
* Three ways this deliberately answers null:
* - the label states no sats, or states it without the word "moms"
* ("Försäljning konsult 25 %" is a margin or a share, not a moms-sats);
* - the label states two different sats, so neither can be trusted;
* - the label also carries a contradicting term (see above).
*/
function inferDomesticSalesRate(
accountNumber: string,
accountName: string | null | undefined,
): number | null {
function inferDomesticSalesRate(accountNumber: string, accountName: string): number | null {
const accountMatch = /^30\d([123])$/.exec(accountNumber)
if (!accountMatch) return null
const name = accountName ?? ''
if (CONTRADICTING_ACCOUNT_NAME.test(name)) return null
if (!accountMatch || CONTRADICTING_ACCOUNT_NAME.test(accountName)) return null
const expectedRate = DOMESTIC_SALES_RATE_BY_SUFFIX[accountMatch[1]]
const namedRates = new Set(
[...name.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map(
(match) => Number(match[1]) / 100,
)
[...accountName.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map((match) => Number(match[1]) / 100),
)
return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null
}
/**
* Ruta 05 accounts that ACCOUNT_TO_BOX already sums, but whose per-rate bucket
* cannot be inferred from the account number.
*
* 3000 "Försäljning inom Sverige" is the BAS gruppkonto for the 30xx range. A
* BAS-conformant company posts to 3001/3002/3003 and never to 3000, but a
* company that does post to it has genuine domestic taxable sales, so ruta 05
* stays the right box and the filed figure is already correct. What is missing
* is only the rate split: unlike 3001/3002/3003 the number carries no sats, so
* `breakdown.invoices.base25/12/6` would not add up to ruta 05.
*
* These accounts therefore contribute a RATE ONLY. Adding them to `accounts`
* would double-count them, since ACCOUNT_TO_BOX already puts them in the sum.
*/
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
export interface DynamicRuta05Accounts {
/** Accounts to add to the ledger fetch and to the ruta 05 sum. */
export interface DynamicVatAccounts {
accounts: string[]
/** account_number → 0.25 | 0.12 | 0.06, for the per-rate base breakdown. */
mappingByAccount: Map<string, AccountVatRutaMapping>
explicitAccounts: Set<string>
rateByAccount: Map<string, number>
/**
* Rates for accounts ALREADY counted in ruta 05 by the static map. Feeds the
* base breakdown only, never the sum. Empty unless the user set a
* "Standard moms" on one of RUTA_05_STATIC_RATE_ACCOUNTS.
*/
staticRateByAccount: Map<string, number>
rcBasisRateByAccount: Map<string, number>
}
const EMPTY: DynamicRuta05Accounts = {
accounts: [],
rateByAccount: new Map(),
staticRateByAccount: new Map(),
}
const emptyDynamicVatAccounts = (): DynamicVatAccounts => ({
accounts: [], mappingByAccount: new Map(), explicitAccounts: new Set(),
rateByAccount: new Map(), staticRateByAccount: new Map(),
rcBasisRateByAccount: new Map(),
})
/**
* Fetch the company-specific ruta 05 accounts.
*
* Excluded:
* - every account in ACCOUNT_TO_BOX. This covers all of ACCOUNT_RUTA (the
* alignment test in lib/vat/__tests__/moms-box-mapping.test.ts fails if the
* mirror ever stops being a superset) and additionally the accounts only
* the mirror maps (3106, 3109, 3521, 3522). Filtering on the superset alone
* keeps this module off vat-declaration.ts, which imports it.
*
* This exclusion is what makes the BAS backfill safe: it sets 3001 = 25 %,
* and without it 3001 would be counted once by ACCOUNT_RUTA and once here,
* doubling ruta 05.
* - RUTA_05_EXCLUDED_ACCOUNTS above.
*
* The company_id filter is explicit rather than left to RLS:
* calculateVatDeclaration is also reached from /api/v1/* on a service client,
* which has no RLS.
*/
export async function fetchDynamicRuta05Accounts(
/** Explicit treatments extend custom accounts; fixed BAS mappings stay authoritative. */
export async function fetchDynamicVatAccounts(
supabase: SupabaseClient,
companyId: string
): Promise<DynamicRuta05Accounts> {
companyId: string,
): Promise<DynamicVatAccounts> {
const rows = await fetchAllRows<{
account_number: string
account_name: string
account_class: number
default_vat_rate: number | string | null
default_vat_treatment: string | null
}>(
({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, default_vat_rate')
.eq('company_id', companyId)
.eq('account_class', 3)
// Deactivated accounts only. is_active is nullable (boolean DEFAULT
// true, never made NOT NULL) and the accounts API treats only an
// explicit false as deactivated, so `eq(true)` would silently drop a
// NULL-flagged konto: the same kind of quiet omission this whole fix
// exists to remove.
.not('is_active', 'is', false)
.order('account_number', { ascending: true })
.range(from, to)
({ from, to }) => supabase.from('chart_of_accounts')
.select('account_number, account_name, account_class, default_vat_rate, default_vat_treatment')
.eq('company_id', companyId)
.in('account_class', [3, 4, 5, 6])
.not('is_active', 'is', false)
.order('account_number', { ascending: true })
.range(from, to),
)
if (rows.length === 0) return EMPTY
const accounts: string[] = []
const rateByAccount = new Map<string, number>()
const staticRateByAccount = new Map<string, number>()
const result = emptyDynamicVatAccounts()
for (const row of rows) {
const account = row.account_number
const rate = row.default_vat_rate === null
? inferDomesticSalesRate(account, row.account_name)
: Number(row.default_vat_rate)
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
const accountClass = Number(row.account_class ?? account.charAt(0))
const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
// Checked before the ACCOUNT_TO_BOX skip: these accounts ARE in that map,
// which is precisely why they need the rate surfaced separately.
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
staticRateByAccount.set(account, rate)
if (isAccountVatTreatment(row.default_vat_treatment)) {
if (ACCOUNT_TO_BOX[account]) {
if (
RUTA_05_STATIC_RATE_ACCOUNTS.has(account) &&
configuredRate !== null && TAXABLE_RATES.includes(configuredRate)
) {
result.staticRateByAccount.set(account, configuredRate)
}
continue
}
const mapping = resolveVatTreatmentRuta(row.default_vat_treatment, accountClass)
if (!mapping) continue
result.explicitAccounts.add(account)
result.mappingByAccount.set(account, mapping)
result.accounts.push(account)
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass)
if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) {
result.rateByAccount.set(account, rate)
}
if (
['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24'].includes(mapping.box) &&
rate !== null && TAXABLE_RATES.includes(rate)
) {
result.rcBasisRateByAccount.set(account, rate)
}
continue
}
if (ACCOUNT_TO_BOX[account]) continue
if (accountClass !== 3) continue
const rate = configuredRate ?? inferDomesticSalesRate(account, row.account_name)
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
if (ACCOUNT_TO_BOX[account]) {
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
result.staticRateByAccount.set(account, rate)
}
continue
}
if (RUTA_05_EXCLUDED_ACCOUNTS.has(account)) continue
result.accounts.push(account)
result.mappingByAccount.set(account, { box: 'ruta05', side: 'credit' })
result.rateByAccount.set(account, rate)
}
return result
}
accounts.push(account)
rateByAccount.set(account, rate)
export async function fetchDynamicRuta05Accounts(
supabase: SupabaseClient,
companyId: string,
): Promise<Pick<DynamicVatAccounts, 'accounts' | 'rateByAccount' | 'staticRateByAccount'>> {
const resolved = await fetchDynamicVatAccounts(supabase, companyId)
return {
accounts: [...resolved.mappingByAccount]
.filter(([account, mapping]) => mapping.box === 'ruta05' && !ACCOUNT_TO_BOX[account])
.map(([account]) => account),
rateByAccount: resolved.rateByAccount,
staticRateByAccount: resolved.staticRateByAccount,
}
return { accounts, rateByAccount, staticRateByAccount }
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import {
defaultRateForVatTreatment,
isVatTreatmentValidForAccountClass,
resolveVatTreatmentRuta,
suggestVatTreatment,
vatTreatmentsForAccountClass,
} from '../account-vat-treatment'
describe('resolveVatTreatmentRuta', () => {
it('maps revenue treatments to their momsdeklaration boxes', () => {
expect(resolveVatTreatmentRuta('standard_25', 3)).toEqual({ box: 'ruta05', side: 'credit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 3)).toEqual({ box: 'ruta41', side: 'credit' })
expect(resolveVatTreatmentRuta('reverse_charge_eu_goods', 3)).toEqual({ box: 'ruta35', side: 'credit' })
expect(resolveVatTreatmentRuta('reverse_charge_eu_services', 3)).toEqual({ box: 'ruta39', side: 'credit' })
expect(resolveVatTreatmentRuta('export_goods', 3)).toEqual({ box: 'ruta36', side: 'credit' })
expect(resolveVatTreatmentRuta('export_services', 3)).toEqual({ box: 'ruta40', side: 'credit' })
expect(resolveVatTreatmentRuta('exempt', 3)).toEqual({ box: 'ruta42', side: 'credit' })
expect(resolveVatTreatmentRuta('vmb', 3)).toEqual({ box: 'ruta07', side: 'credit' })
expect(resolveVatTreatmentRuta('rental_voluntary', 3)).toEqual({ box: 'ruta08', side: 'credit' })
})
it('maps purchase treatments by purchase class', () => {
expect(resolveVatTreatmentRuta('reverse_charge_eu_goods', 4)).toEqual({ box: 'ruta20', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_eu_services', 4)).toEqual({ box: 'ruta21', side: 'debit' })
expect(resolveVatTreatmentRuta('export_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4)).toEqual({ box: 'ruta23', side: 'debit' })
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 5)).toEqual({ box: 'ruta24', side: 'debit' })
expect(resolveVatTreatmentRuta('export_goods', 4)).toEqual({ box: 'ruta50', side: 'debit' })
expect(resolveVatTreatmentRuta('exempt', 4)).toBeNull()
})
})
describe('vat treatment applicability', () => {
it('exposes only treatments that resolve for the account class', () => {
expect(vatTreatmentsForAccountClass(3)).toContain('vmb')
expect(vatTreatmentsForAccountClass(5)).not.toContain('vmb')
expect(isVatTreatmentValidForAccountClass('reverse_charge_eu_services', 5)).toBe(true)
expect(isVatTreatmentValidForAccountClass('standard_25', 5)).toBe(false)
})
})
describe('suggestVatTreatment', () => {
it('suggests the issue examples from labels, not SIE metadata', () => {
expect(suggestVatTreatment('3041', 'Försäljning tjänst 25% sv')).toEqual({
treatment: 'standard_25', rate: 0.25,
})
expect(suggestVatTreatment('4056', 'Inköp varor 25% EU')).toEqual({
treatment: 'reverse_charge_eu_goods', rate: 0.25,
})
})
it('does not guess from an account number alone', () => {
expect(suggestVatTreatment('3041', 'Projektintäkt')).toBeNull()
expect(suggestVatTreatment('4056', 'Projektkostnad')).toBeNull()
})
it('matches EU as a term, not a substring inside another word', () => {
expect(suggestVatTreatment('4056', 'Reumatologiska varor 25%')).toBeNull()
expect(suggestVatTreatment('4056', 'Inköp EU-varor 25%')).toEqual({
treatment: 'reverse_charge_eu_goods',
rate: 0.25,
})
})
it('does not assume a purchase-side reverse-charge rate', () => {
expect(defaultRateForVatTreatment('reverse_charge_eu_goods', 4)).toBeNull()
expect(defaultRateForVatTreatment('reverse_charge_eu_services', 5)).toBeNull()
expect(defaultRateForVatTreatment('reverse_charge_domestic', 6)).toBeNull()
expect(defaultRateForVatTreatment('export_goods', 4)).toBeNull()
expect(suggestVatTreatment('4056', 'Inköp varor EU')).toEqual({
treatment: 'reverse_charge_eu_goods',
rate: null,
})
})
it('does not use a gross account rate for VMB', () => {
expect(defaultRateForVatTreatment('vmb', 3)).toBeNull()
expect(suggestVatTreatment('3021', 'Försäljning begagnat 25% VMB')).toEqual({
treatment: 'vmb',
rate: null,
})
})
})
+121
View File
@@ -0,0 +1,121 @@
import type { VatDeclarationRutor } from '@/types'
export const ACCOUNT_VAT_TREATMENTS = [
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
'reverse_charge_domestic', 'reverse_charge_eu_goods',
'reverse_charge_eu_services', 'export_goods', 'export_services',
'vmb', 'rental_voluntary',
] as const
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
export interface AccountVatRutaMapping {
box: keyof VatDeclarationRutor
side: 'credit' | 'debit'
}
const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null> = {
standard_25: 'ruta05', reduced_12: 'ruta05', reduced_6: 'ruta05',
exempt: 'ruta42', reverse_charge_domestic: 'ruta41',
reverse_charge_eu_goods: 'ruta35', reverse_charge_eu_services: 'ruta39',
export_goods: 'ruta36', export_services: 'ruta40', vmb: 'ruta07',
rental_voluntary: 'ruta08',
}
export function resolveVatTreatmentRuta(
treatment: AccountVatTreatment,
accountClass: number,
): AccountVatRutaMapping | null {
if (accountClass === 3) {
const box = REVENUE_RUTA[treatment]
return box ? { box, side: 'credit' } : null
}
if (accountClass < 4 || accountClass > 6) return null
if (treatment === 'reverse_charge_eu_goods') return { box: 'ruta20', side: 'debit' }
if (treatment === 'reverse_charge_eu_services') return { box: 'ruta21', side: 'debit' }
if (treatment === 'export_services') return { box: 'ruta22', side: 'debit' }
if (treatment === 'reverse_charge_domestic') {
return { box: accountClass === 4 ? 'ruta23' : 'ruta24', side: 'debit' }
}
if (treatment === 'export_goods') return { box: 'ruta50', side: 'debit' }
return null
}
export function defaultRateForVatTreatment(
treatment: AccountVatTreatment,
accountClass: number,
): number | null {
if (treatment === 'standard_25') return 0.25
if (treatment === 'reduced_12') return 0.12
if (treatment === 'reduced_6') return 0.06
if (treatment === 'exempt') return 0
if (treatment === 'rental_voluntary') return 0.25
if (treatment === 'vmb') return null
return accountClass >= 4 && accountClass <= 6 ? null : 0
}
export function isAccountVatTreatment(value: unknown): value is AccountVatTreatment {
return typeof value === 'string' &&
(ACCOUNT_VAT_TREATMENTS as readonly string[]).includes(value)
}
export function vatTreatmentsForAccountClass(accountClass: number): AccountVatTreatment[] {
return ACCOUNT_VAT_TREATMENTS.filter(
(treatment) => resolveVatTreatmentRuta(treatment, accountClass) !== null,
)
}
export function isVatTreatmentValidForAccountClass(
treatment: AccountVatTreatment,
accountClass: number,
): boolean {
return resolveVatTreatmentRuta(treatment, accountClass) !== null
}
export interface SuggestedVatTreatment {
treatment: AccountVatTreatment
rate: number | null
}
/**
* Suggest a VAT treatment from a SIE account label. SIE #SRU and #KTYP are
* deliberately excluded: neither record carries a momsdeklaration treatment.
* Suggestions are persisted only after the user reviews the import mapping.
*/
export function suggestVatTreatment(
accountNumber: string,
accountName: string,
): SuggestedVatTreatment | null {
const accountClass = Number(accountNumber.charAt(0))
if (accountClass < 3 || accountClass > 6) return null
const name = accountName.toLocaleLowerCase('sv-SE')
const percent = /\b(25|12|6)\s*%/.exec(name)
const rate = percent ? Number(percent[1]) / 100 : null
if (accountClass === 3) {
if (/vmb|vinstmarginal/.test(name)) return { treatment: 'vmb', rate: null }
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) {
return { treatment: 'rental_voluntary', rate: rate ?? 0.25 }
}
if (/omvänd/.test(name)) return { treatment: 'reverse_charge_domestic', rate: 0 }
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
if (/export|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate: 0 }
if (/export|utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate: 0 }
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate: 0 }
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate: 0 }
if (/försälj|forsalj|intäkt|intakt/.test(name) && percent) {
return {
treatment: rate === 0.12 ? 'reduced_12' : rate === 0.06 ? 'reduced_6' : 'standard_25',
rate,
}
}
return null
}
if (/omvänd/.test(name) && /sverige|svensk|inrikes/.test(name)) return { treatment: 'reverse_charge_domestic', rate }
if (/import|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate }
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate }
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate }
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate }
return null
}