* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off A foreign supplier charging no Swedish VAT is normally omvand skattskyldighet. With the reverse-charge switch off, createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the momsdeklaration takes a shape Skatteverket rejects. For a fully deductible purchase the net moms att betala is unchanged, which is exactly why this goes unnoticed. The form already auto-ticks reverse charge for eu_business but not for non_eu_business, so that path slips through silently. Adds a pure helper plus a non-blocking banner cloned from the existing rc_account_warning block. Deliberately silent for swedish_business, where 0 % is a genuine exemption that belongs in no ruta at all, and phrased as a question rather than an assertion: a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge, and pushing that user into ticking the switch would manufacture a new wrong verifikat. Does not add the exempt/import/other picker the issue proposes: supplier_invoices.vat_treatment is metadata that no booking or ruta mapping reads, and the codebase cannot book import VAT at all, so an import option would imply ruta 50/60 were handled when they are not. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint Review flagged that the most common foreign document a Swedish small company sees is an invoice carrying the supplier's OWN local VAT, booked at 0 % Swedish VAT with reverse charge correctly off. The banner fires there, and the previous copy only offered "momsfri av annat skal, till exempel en varuimport" as the way out, which does not describe that invoice at all: it is not VAT-free, it carries foreign VAT. Names both legitimate cases explicitly and says 0 % is correct in them, so the hint cannot read as an instruction to tick reverse charge on a purchase where that would produce a wrong verifikat. Title also narrowed to "utan svensk moms" for the same reason. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
LEGAL_VAT_RATES,
|
|
isLegalVatRate,
|
|
normalizeVatRateToDecimal,
|
|
findIllegalVatRateRow,
|
|
findReverseChargeAccountWarningRows,
|
|
findUnflaggedForeignZeroVatRows,
|
|
} from '@/lib/vat/supplier-invoice-line-checks'
|
|
|
|
describe('LEGAL_VAT_RATES', () => {
|
|
it('is exactly the legal Swedish set as decimal fractions', () => {
|
|
expect(LEGAL_VAT_RATES).toEqual([0.25, 0.12, 0.06, 0])
|
|
})
|
|
})
|
|
|
|
describe('isLegalVatRate', () => {
|
|
it.each([0.25, 0.12, 0.06, 0])('accepts %s', (rate) => {
|
|
expect(isLegalVatRate(rate)).toBe(true)
|
|
})
|
|
|
|
it.each([0.13, 0.17, 0.2, 0.1, 1, -0.25])('rejects %s', (rate) => {
|
|
expect(isLegalVatRate(rate)).toBe(false)
|
|
})
|
|
|
|
it('accepts a rate produced the way the form parses free text (typing 12 -> 12/100)', () => {
|
|
// VatRateCell stores parsed-percent / 100; the division must land exactly
|
|
// on the preset double for the strict includes() match to hold.
|
|
expect(isLegalVatRate(12 / 100)).toBe(true)
|
|
expect(isLegalVatRate(6 / 100)).toBe(true)
|
|
expect(isLegalVatRate(25 / 100)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('normalizeVatRateToDecimal', () => {
|
|
it.each([
|
|
[25, 0.25],
|
|
[12, 0.12],
|
|
[6, 0.06],
|
|
])('converts percent-integer %s to decimal %s', (percent, decimal) => {
|
|
expect(normalizeVatRateToDecimal(percent)).toBe(decimal)
|
|
})
|
|
|
|
it.each([0, 0.06, 0.12, 0.25])('passes already-decimal %s through unchanged', (rate) => {
|
|
expect(normalizeVatRateToDecimal(rate)).toBe(rate)
|
|
})
|
|
|
|
it.each([19, 20, 0.19, 0.2, 1, 100, -0.25, -25])(
|
|
'maps non-statutory rate %s to 0 (strict Swedish allowlist)',
|
|
(rate) => {
|
|
expect(normalizeVatRateToDecimal(rate)).toBe(0)
|
|
},
|
|
)
|
|
|
|
it('maps missing or non-finite input to 0', () => {
|
|
expect(normalizeVatRateToDecimal(null)).toBe(0)
|
|
expect(normalizeVatRateToDecimal(undefined)).toBe(0)
|
|
expect(normalizeVatRateToDecimal(Number.NaN)).toBe(0)
|
|
expect(normalizeVatRateToDecimal(Number.POSITIVE_INFINITY)).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('findIllegalVatRateRow', () => {
|
|
it('returns -1 when every line is legal', () => {
|
|
const items = [{ vat_rate: 0.25 }, { vat_rate: 0.12 }, { vat_rate: 0 }]
|
|
expect(findIllegalVatRateRow(items)).toBe(-1)
|
|
})
|
|
|
|
it('returns -1 for an empty list', () => {
|
|
expect(findIllegalVatRateRow([])).toBe(-1)
|
|
})
|
|
|
|
it('returns the index of the first illegal line', () => {
|
|
const items = [{ vat_rate: 0.25 }, { vat_rate: 0.13 }, { vat_rate: 0.17 }]
|
|
expect(findIllegalVatRateRow(items)).toBe(1)
|
|
})
|
|
|
|
it('flags a 13 % rate typed into the free-text cell (13 / 100)', () => {
|
|
expect(findIllegalVatRateRow([{ vat_rate: 13 / 100 }])).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('findReverseChargeAccountWarningRows', () => {
|
|
it('flags class 1 and class 6 accounts', () => {
|
|
const items = [
|
|
{ account_number: '1930' },
|
|
{ account_number: '4010' },
|
|
{ account_number: '6540' },
|
|
]
|
|
expect(findReverseChargeAccountWarningRows(items)).toEqual([0, 2])
|
|
})
|
|
|
|
it('does not flag the expected 4xxx/5xxx cost accounts', () => {
|
|
const items = [{ account_number: '4515' }, { account_number: '5420' }]
|
|
expect(findReverseChargeAccountWarningRows(items)).toEqual([])
|
|
})
|
|
|
|
it('skips rows without an account (owned by the account-missing check)', () => {
|
|
const items = [{ account_number: '' }, { account_number: '1220' }]
|
|
expect(findReverseChargeAccountWarningRows(items)).toEqual([1])
|
|
})
|
|
|
|
it('returns an empty list for no items', () => {
|
|
expect(findReverseChargeAccountWarningRows([])).toEqual([])
|
|
})
|
|
|
|
it('treats account numbers as strings, not numbers', () => {
|
|
// '16' and '60' start with 1/6 as strings; a numeric range check would
|
|
// classify them differently.
|
|
expect(
|
|
findReverseChargeAccountWarningRows([
|
|
{ account_number: '1680' },
|
|
{ account_number: '6072' },
|
|
{ account_number: '7010' },
|
|
]),
|
|
).toEqual([0, 1])
|
|
})
|
|
})
|
|
|
|
describe('findUnflaggedForeignZeroVatRows', () => {
|
|
const zero = [{ vat_rate: 0 }]
|
|
|
|
it('flags every 0 % line on an EU supplier invoice with reverse charge off', () => {
|
|
const items = [{ vat_rate: 0 }, { vat_rate: 0.25 }, { vat_rate: 0 }]
|
|
expect(findUnflaggedForeignZeroVatRows(items, false, 'eu_business')).toEqual([0, 2])
|
|
})
|
|
|
|
it('flags a 0 % line on a non-EU supplier invoice', () => {
|
|
// The form auto-ticks reverse charge for eu_business but not for
|
|
// non_eu_business, so this is the case that actually slips through.
|
|
expect(findUnflaggedForeignZeroVatRows(zero, false, 'non_eu_business')).toEqual([0])
|
|
})
|
|
|
|
it('stays silent for a Swedish supplier at 0 %', () => {
|
|
// Bankavgift, forsakring, hyra: a genuine exemption that belongs in no
|
|
// ruta at all. Flagging it would be noise, not a finding.
|
|
expect(findUnflaggedForeignZeroVatRows(zero, false, 'swedish_business')).toEqual([])
|
|
})
|
|
|
|
it('stays silent when reverse charge is already on', () => {
|
|
const items = [{ vat_rate: 0 }, { vat_rate: 0 }]
|
|
expect(findUnflaggedForeignZeroVatRows(items, true, 'eu_business')).toEqual([])
|
|
expect(findUnflaggedForeignZeroVatRows(items, true, 'non_eu_business')).toEqual([])
|
|
})
|
|
|
|
it('stays silent before a supplier is picked', () => {
|
|
expect(findUnflaggedForeignZeroVatRows(zero, false, undefined)).toEqual([])
|
|
expect(findUnflaggedForeignZeroVatRows(zero, false, null)).toEqual([])
|
|
expect(findUnflaggedForeignZeroVatRows(zero, false, '')).toEqual([])
|
|
})
|
|
|
|
it('stays silent when the foreign supplier charged VAT on every line', () => {
|
|
const items = [{ vat_rate: 0.25 }, { vat_rate: 0.12 }, { vat_rate: 0.06 }]
|
|
expect(findUnflaggedForeignZeroVatRows(items, false, 'eu_business')).toEqual([])
|
|
})
|
|
|
|
it('returns an empty list for no items', () => {
|
|
expect(findUnflaggedForeignZeroVatRows([], false, 'eu_business')).toEqual([])
|
|
})
|
|
|
|
it('ignores an unknown supplier type rather than guessing it is foreign', () => {
|
|
expect(findUnflaggedForeignZeroVatRows(zero, false, 'private_person')).toEqual([])
|
|
})
|
|
})
|