fix(supplier-invoices): warn on class 1/6 accounts for reverse charge lines (#1034)
* fix(supplier-invoices): warn on class 1/6 accounts for reverse charge lines (#863) Item 2 of #863: when omvand skattskyldighet is on, lines booked on an account starting with 1 (assets) or 6 draw a non-blocking warning banner in the Kontering card naming the rows; reverse charge purchases normally sit on 4xxx/5xxx cost accounts. Advisory only, since class 6 has legitimate reverse charge uses (e.g. 6540 IT-tjanster for EU cloud services), so submission is never blocked. Item 1 (block VAT rates outside the legal set 25/12/6/0) already shipped in PR #902; this change extracts that check plus the new one into a pure tested helper, lib/vat/supplier-invoice-line-checks.ts, which is now also the single source for the legal rate list used by the VAT rate preset dropdown. Item 3 (confirming the reason for a 0 % rate) is deferred: it is a UX design question, not a validation gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(vat): note food-rate transition dates above LEGAL_VAT_RATES Compliance-bot finding on PR #1034: the allow-list comment now records that livsmedel moved 12 % to 6 % on 1 April 2026 (Prop. 2025/26:55, ML 2023:200) and that the reduction is legislated to revert after 31 December 2027, when 6 % stays legal for books/transport but stops being the food rate. The static list cannot express per-category temporal validity; revisit at the reversion. Comment-only change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6bd85f94b6
commit
a95872928e
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
LEGAL_VAT_RATES,
|
||||
isLegalVatRate,
|
||||
findIllegalVatRateRow,
|
||||
findReverseChargeAccountWarningRows,
|
||||
} 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('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])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// Pure submit-time checks for supplier invoice line items (issue #863).
|
||||
// Kept free of React so the rules can be unit-tested and reused.
|
||||
|
||||
// Only 25/12/6/0 % are legal Swedish VAT rates (ML 2023:200). The supplier
|
||||
// invoice form stores rates as decimal fractions (0.25 = 25 %); this list is
|
||||
// also the preset dropdown in the form's VAT rate cell.
|
||||
// Note on the food rate: livsmedel moved from 12 % to 6 % on 1 April 2026
|
||||
// (Prop. 2025/26:55, ML 2023:200), and the reduction is currently legislated
|
||||
// to revert after 31 December 2027; 6 % then remains legal (books, transport)
|
||||
// but stops being the food rate. This static allow-list cannot express
|
||||
// per-category temporal validity, so revisit at the reversion date.
|
||||
export const LEGAL_VAT_RATES: readonly number[] = [0.25, 0.12, 0.06, 0]
|
||||
|
||||
export function isLegalVatRate(rate: number): boolean {
|
||||
return LEGAL_VAT_RATES.includes(rate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the first line whose VAT rate falls outside the legal Swedish set,
|
||||
* or -1 when every line is legal. Reverse charge invoices should skip this
|
||||
* check: their line vat_rate is forced to 0 and the self-assessed rate comes
|
||||
* from a fixed select that only offers legal rates.
|
||||
*/
|
||||
export function findIllegalVatRateRow(
|
||||
items: ReadonlyArray<{ vat_rate: number }>,
|
||||
): number {
|
||||
return items.findIndex((item) => !isLegalVatRate(item.vat_rate))
|
||||
}
|
||||
|
||||
/**
|
||||
* Indices of lines that look mis-accounted for a reverse charge invoice:
|
||||
* omvand skattskyldighet purchases are normally booked on cost accounts
|
||||
* (4xxx/5xxx), so a line on a class 1 (assets) or class 6 account is worth a
|
||||
* second look. Advisory only, never blocking: class 6 has legitimate reverse
|
||||
* charge uses (e.g. 6540 IT-tjanster for EU cloud services).
|
||||
*
|
||||
* Account numbers are strings (identifiers, not quantities); rows without an
|
||||
* account yet are skipped, the separate account-missing check owns those.
|
||||
*/
|
||||
export function findReverseChargeAccountWarningRows(
|
||||
items: ReadonlyArray<{ account_number: string }>,
|
||||
): number[] {
|
||||
const rows: number[] = []
|
||||
items.forEach((item, index) => {
|
||||
const account = item.account_number
|
||||
if (account && (account.startsWith('1') || account.startsWith('6'))) {
|
||||
rows.push(index)
|
||||
}
|
||||
})
|
||||
return rows
|
||||
}
|
||||
Reference in New Issue
Block a user