Files
accounted/lib/vat/__tests__/supplier-invoice-line-checks.test.ts
T
Jakob WennbergandClaude Opus 5 10a7d961f3 fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255)
* 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>
2026-07-28 18:41:25 +02:00

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([])
})
})