fix(vat): complete account treatment enforcement (#1593)
* fix(vat): complete account treatment enforcement * docs(api): refresh account endpoint skill * fix(mcp): preserve ruta 05 compatibility * test(vat): seed migration constraint fixtures * docs(vat): clarify treatment precedence --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
edfdbe2d2a
commit
86f0b70fdd
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
isVatTreatmentValidForAccountClass,
|
||||
resolveVatTreatmentRuta,
|
||||
suggestVatTreatment,
|
||||
vatTreatmentsForAccountClass,
|
||||
} from '../account-vat-treatment'
|
||||
|
||||
describe('resolveVatTreatmentRuta', () => {
|
||||
@@ -23,23 +21,15 @@ describe('resolveVatTreatmentRuta', () => {
|
||||
it('maps purchase treatments by purchase class', () => {
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_eu_goods', 4)).toEqual({ box: 'ruta20', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_eu_services', 4)).toEqual({ box: 'ruta21', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('export_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_non_eu_services', 5)).toEqual({ box: 'ruta22', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4)).toEqual({ box: 'ruta23', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 4, '4425')).toEqual({ box: 'ruta24', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('reverse_charge_domestic', 5)).toEqual({ box: 'ruta24', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('export_goods', 4)).toEqual({ box: 'ruta50', side: 'debit' })
|
||||
expect(resolveVatTreatmentRuta('export_goods', 4)).toBeNull()
|
||||
expect(resolveVatTreatmentRuta('exempt', 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vat treatment applicability', () => {
|
||||
it('exposes only treatments that resolve for the account class', () => {
|
||||
expect(vatTreatmentsForAccountClass(3)).toContain('vmb')
|
||||
expect(vatTreatmentsForAccountClass(5)).not.toContain('vmb')
|
||||
expect(isVatTreatmentValidForAccountClass('reverse_charge_eu_services', 5)).toBe(true)
|
||||
expect(isVatTreatmentValidForAccountClass('standard_25', 5)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestVatTreatment', () => {
|
||||
it('suggests the issue examples from labels, not SIE metadata', () => {
|
||||
expect(suggestVatTreatment('3041', 'Försäljning tjänst 25% sv')).toEqual({
|
||||
@@ -55,30 +45,30 @@ describe('suggestVatTreatment', () => {
|
||||
expect(suggestVatTreatment('4056', 'Projektkostnad')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches EU as a term, not a substring inside another word', () => {
|
||||
expect(suggestVatTreatment('4056', 'Reumatologiska varor 25%')).toBeNull()
|
||||
expect(suggestVatTreatment('4056', 'Inköp EU-varor 25%')).toEqual({
|
||||
treatment: 'reverse_charge_eu_goods',
|
||||
rate: 0.25,
|
||||
it('does not suggest an unsupported purchase treatment for imports of goods', () => {
|
||||
expect(suggestVatTreatment('4545', 'Import varor utanför EU 25%')).toBeNull()
|
||||
})
|
||||
|
||||
it('checks outside-EU labels before the generic EU matcher', () => {
|
||||
expect(suggestVatTreatment('3048', 'Export tjänster utanför EU')).toEqual({
|
||||
treatment: 'export_services', rate: 0,
|
||||
})
|
||||
expect(suggestVatTreatment('3108', 'Försäljning varor till annat EU-land, momsfri')).toEqual({
|
||||
treatment: 'reverse_charge_eu_goods', rate: 0,
|
||||
})
|
||||
expect(suggestVatTreatment('6545', 'Inköp tjänster utanför EU 25%')).toEqual({
|
||||
treatment: 'reverse_charge_non_eu_services', rate: 0.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not assume a purchase-side reverse-charge rate', () => {
|
||||
expect(defaultRateForVatTreatment('reverse_charge_eu_goods', 4)).toBeNull()
|
||||
expect(defaultRateForVatTreatment('reverse_charge_eu_services', 5)).toBeNull()
|
||||
expect(defaultRateForVatTreatment('reverse_charge_domestic', 6)).toBeNull()
|
||||
expect(defaultRateForVatTreatment('export_goods', 4)).toBeNull()
|
||||
expect(suggestVatTreatment('4056', 'Inköp varor EU')).toEqual({
|
||||
treatment: 'reverse_charge_eu_goods',
|
||||
rate: null,
|
||||
})
|
||||
it('does not match EU inside an unrelated word', () => {
|
||||
expect(suggestVatTreatment('4010', 'Reumatologiska varor')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not use a gross account rate for VMB', () => {
|
||||
it('keeps VMB without a generic booking rate', () => {
|
||||
expect(suggestVatTreatment('3211', 'Försäljning VMB')).toEqual({
|
||||
treatment: 'vmb', rate: null,
|
||||
})
|
||||
expect(defaultRateForVatTreatment('vmb', 3)).toBeNull()
|
||||
expect(suggestVatTreatment('3021', 'Försäljning begagnat 25% VMB')).toEqual({
|
||||
treatment: 'vmb',
|
||||
rate: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { VatDeclarationRutor } from '@/types'
|
||||
export const ACCOUNT_VAT_TREATMENTS = [
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'exempt',
|
||||
'reverse_charge_domestic', 'reverse_charge_eu_goods',
|
||||
'reverse_charge_eu_services', 'export_goods', 'export_services',
|
||||
'vmb', 'rental_voluntary',
|
||||
'reverse_charge_eu_services', 'reverse_charge_non_eu_services',
|
||||
'export_goods', 'export_services', 'vmb', 'rental_voluntary',
|
||||
] as const
|
||||
|
||||
export type AccountVatTreatment = typeof ACCOUNT_VAT_TREATMENTS[number]
|
||||
export type AccountVatRate = 0 | 0.06 | 0.12 | 0.25 | null
|
||||
|
||||
export interface AccountVatRutaMapping {
|
||||
box: keyof VatDeclarationRutor
|
||||
@@ -18,6 +19,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
|
||||
standard_25: 'ruta05', reduced_12: 'ruta05', reduced_6: 'ruta05',
|
||||
exempt: 'ruta42', reverse_charge_domestic: 'ruta41',
|
||||
reverse_charge_eu_goods: 'ruta35', reverse_charge_eu_services: 'ruta39',
|
||||
reverse_charge_non_eu_services: null,
|
||||
export_goods: 'ruta36', export_services: 'ruta40', vmb: 'ruta07',
|
||||
rental_voluntary: 'ruta08',
|
||||
}
|
||||
@@ -25,6 +27,7 @@ const REVENUE_RUTA: Record<AccountVatTreatment, keyof VatDeclarationRutor | null
|
||||
export function resolveVatTreatmentRuta(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
accountNumber?: string,
|
||||
): AccountVatRutaMapping | null {
|
||||
if (accountClass === 3) {
|
||||
const box = REVENUE_RUTA[treatment]
|
||||
@@ -33,25 +36,40 @@ export function resolveVatTreatmentRuta(
|
||||
if (accountClass < 4 || accountClass > 6) return null
|
||||
if (treatment === 'reverse_charge_eu_goods') return { box: 'ruta20', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_eu_services') return { box: 'ruta21', side: 'debit' }
|
||||
if (treatment === 'export_services') return { box: 'ruta22', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_non_eu_services') return { box: 'ruta22', side: 'debit' }
|
||||
if (treatment === 'reverse_charge_domestic') {
|
||||
return { box: accountClass === 4 ? 'ruta23' : 'ruta24', side: 'debit' }
|
||||
const isKnownServiceAccount = accountNumber != null && /^442[567]$/.test(accountNumber)
|
||||
return { box: accountClass === 4 && !isKnownServiceAccount ? 'ruta23' : 'ruta24', side: 'debit' }
|
||||
}
|
||||
if (treatment === 'export_goods') return { box: 'ruta50', side: 'debit' }
|
||||
return null
|
||||
}
|
||||
|
||||
export function isVatTreatmentAllowedForAccountClass(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): boolean {
|
||||
return resolveVatTreatmentRuta(treatment, accountClass) !== null
|
||||
}
|
||||
|
||||
export function vatTreatmentsForAccountClass(accountClass: number | null): AccountVatTreatment[] {
|
||||
if (accountClass === null) return []
|
||||
return ACCOUNT_VAT_TREATMENTS.filter((treatment) =>
|
||||
isVatTreatmentAllowedForAccountClass(treatment, accountClass)
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultRateForVatTreatment(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): number | null {
|
||||
): AccountVatRate {
|
||||
if (treatment === 'standard_25') return 0.25
|
||||
if (treatment === 'reduced_12') return 0.12
|
||||
if (treatment === 'reduced_6') return 0.06
|
||||
if (treatment === 'exempt') return 0
|
||||
if (treatment === 'rental_voluntary') return 0.25
|
||||
if (treatment === 'vmb') return null
|
||||
return accountClass >= 4 && accountClass <= 6 ? null : 0
|
||||
if (treatment === 'rental_voluntary') return 0.25
|
||||
if (treatment === 'export_goods' || treatment === 'export_services') return 0
|
||||
return accountClass >= 4 && accountClass <= 6 ? 0.25 : 0
|
||||
}
|
||||
|
||||
export function isAccountVatTreatment(value: unknown): value is AccountVatTreatment {
|
||||
@@ -59,19 +77,6 @@ export function isAccountVatTreatment(value: unknown): value is AccountVatTreatm
|
||||
(ACCOUNT_VAT_TREATMENTS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function vatTreatmentsForAccountClass(accountClass: number): AccountVatTreatment[] {
|
||||
return ACCOUNT_VAT_TREATMENTS.filter(
|
||||
(treatment) => resolveVatTreatmentRuta(treatment, accountClass) !== null,
|
||||
)
|
||||
}
|
||||
|
||||
export function isVatTreatmentValidForAccountClass(
|
||||
treatment: AccountVatTreatment,
|
||||
accountClass: number,
|
||||
): boolean {
|
||||
return resolveVatTreatmentRuta(treatment, accountClass) !== null
|
||||
}
|
||||
|
||||
export interface SuggestedVatTreatment {
|
||||
treatment: AccountVatTreatment
|
||||
rate: number | null
|
||||
@@ -90,19 +95,17 @@ export function suggestVatTreatment(
|
||||
if (accountClass < 3 || accountClass > 6) return null
|
||||
const name = accountName.toLocaleLowerCase('sv-SE')
|
||||
const percent = /\b(25|12|6)\s*%/.exec(name)
|
||||
const rate = percent ? Number(percent[1]) / 100 : null
|
||||
const rate = percent ? Number(percent[1]) / 100 : 0.25
|
||||
|
||||
if (accountClass === 3) {
|
||||
if (/vmb|vinstmarginal/.test(name)) return { treatment: 'vmb', rate: null }
|
||||
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) {
|
||||
return { treatment: 'rental_voluntary', rate: rate ?? 0.25 }
|
||||
}
|
||||
if (/hyra|uthyrning/.test(name) && /frivillig/.test(name)) return { treatment: 'rental_voluntary', rate }
|
||||
if (/omvänd/.test(name)) return { treatment: 'reverse_charge_domestic', rate: 0 }
|
||||
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
|
||||
if (/export|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate: 0 }
|
||||
if (/export|utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate: 0 }
|
||||
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate: 0 }
|
||||
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate: 0 }
|
||||
if (/momsfri|utan moms/.test(name)) return { treatment: 'exempt', rate: 0 }
|
||||
if (/försälj|forsalj|intäkt|intakt/.test(name) && percent) {
|
||||
return {
|
||||
treatment: rate === 0.12 ? 'reduced_12' : rate === 0.06 ? 'reduced_6' : 'standard_25',
|
||||
@@ -113,8 +116,10 @@ export function suggestVatTreatment(
|
||||
}
|
||||
|
||||
if (/omvänd/.test(name) && /sverige|svensk|inrikes/.test(name)) return { treatment: 'reverse_charge_domestic', rate }
|
||||
if (/import|utanför eu/.test(name) && /var/.test(name)) return { treatment: 'export_goods', rate }
|
||||
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'export_services', rate }
|
||||
if (/utanför eu|import/.test(name) && /var/.test(name)) return null
|
||||
if (/utanför eu/.test(name) && /tjänst|tjanst/.test(name)) {
|
||||
return { treatment: 'reverse_charge_non_eu_services', rate }
|
||||
}
|
||||
if (/\beu\b/.test(name) && /var/.test(name)) return { treatment: 'reverse_charge_eu_goods', rate }
|
||||
if (/\beu\b/.test(name) && /tjänst|tjanst/.test(name)) return { treatment: 'reverse_charge_eu_services', rate }
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user