From a95872928e604063f576ce815595259cf23bc14e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:14:23 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .../NewSupplierInvoiceForm.tsx | 39 ++++++-- .../supplier-invoice-line-checks.test.ts | 88 +++++++++++++++++++ lib/vat/supplier-invoice-line-checks.ts | 51 +++++++++++ messages/en.json | 1 + messages/sv.json | 1 + 5 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 lib/vat/__tests__/supplier-invoice-line-checks.test.ts create mode 100644 lib/vat/supplier-invoice-line-checks.ts diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index 7815d2a2..b81bd9b1 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -31,7 +31,12 @@ import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import DocumentUploadZone, { type UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' -import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react' +import { + LEGAL_VAT_RATES, + findIllegalVatRateRow, + findReverseChargeAccountWarningRows, +} from '@/lib/vat/supplier-invoice-line-checks' +import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react' import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types' interface LineItem { @@ -141,8 +146,6 @@ function rateToPctString(rate: number): string { return Number.isFinite(pct) ? String(pct) : '' } -const VAT_RATE_PRESETS = [0.25, 0.12, 0.06, 0] - function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) => void }) { const t = useTranslations('supplier_invoice_editor') const inputRef = useRef(null) @@ -212,7 +215,7 @@ function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) - {VAT_RATE_PRESETS.map((preset) => ( + {LEGAL_VAT_RATES.map((preset) => ( onChange(preset)} @@ -444,6 +447,14 @@ export default function NewSupplierInvoiceForm({ !periods.some((p) => watchedInvoiceDate >= p.period_start && watchedInvoiceDate <= p.period_end) const showNoPeriodWarning = willBookAtRegistration && invoiceDateOutsidePeriod + // Advisory nudge (#863 item 2): reverse charge purchases are normally booked + // on cost accounts (4xxx/5xxx), so flag lines sitting on a class 1 or 6 + // account while omvand skattskyldighet is on. Never blocks submission: class + // 6 has legitimate reverse charge uses (e.g. 6540 for EU cloud services). + const rcAccountWarningRows = watchedReverseCharge + ? findReverseChargeAccountWarningRows(watchedItems ?? []) + : [] + useEffect(() => { fetchSuppliers() fetchAccounts() @@ -1133,9 +1144,7 @@ export default function NewSupplierInvoiceForm({ // illegal rates here. Reverse-charge invoices skip this: their line vat_rate // is forced to 0 and RcRateSelect already restricts the self-assessed rate. if (!data.reverse_charge) { - const rowWithIllegalRate = data.items.findIndex( - (item) => !VAT_RATE_PRESETS.includes(item.vat_rate), - ) + const rowWithIllegalRate = findIllegalVatRateRow(data.items) if (rowWithIllegalRate !== -1) { toast({ title: t('illegal_vat_rate_title'), @@ -1773,6 +1782,22 @@ export default function NewSupplierInvoiceForm({ + {/* Non-blocking account-range hint for reverse charge (#863 item 2) */} + {rcAccountWarningRows.length > 0 && ( +
+ +

+ {t('rc_account_warning', { + count: rcAccountWarningRows.length, + rows: rcAccountWarningRows.map((i) => i + 1).join(', '), + })} +

+
+ )} + {/* Desktop table */}
diff --git a/lib/vat/__tests__/supplier-invoice-line-checks.test.ts b/lib/vat/__tests__/supplier-invoice-line-checks.test.ts new file mode 100644 index 00000000..967b5c9a --- /dev/null +++ b/lib/vat/__tests__/supplier-invoice-line-checks.test.ts @@ -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]) + }) +}) diff --git a/lib/vat/supplier-invoice-line-checks.ts b/lib/vat/supplier-invoice-line-checks.ts new file mode 100644 index 00000000..f730c19c --- /dev/null +++ b/lib/vat/supplier-invoice-line-checks.ts @@ -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 +} diff --git a/messages/en.json b/messages/en.json index 2d4566fc..ed05bfbe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3204,6 +3204,7 @@ "account_missing_description": "Select an expense account for line {row}.", "illegal_vat_rate_title": "Invalid VAT rate", "illegal_vat_rate_description": "Line {row} has VAT rate {rate} %. The legal Swedish VAT rates are 25, 12, 6 or 0 %.", + "rc_account_warning": "Reverse charge: {count, plural, =1 {line {rows} uses an account starting with 1 or 6} other {lines {rows} use accounts starting with 1 or 6}}. Reverse charge purchases are normally booked on cost accounts (4xxx/5xxx). Double-check the account choice.", "expense_registered_title": "Expense registered", "invoice_registered_title": "Invoice registered", "arrival_number_label": "Arrival number: {number}", diff --git a/messages/sv.json b/messages/sv.json index 26400e04..411019db 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3204,6 +3204,7 @@ "account_missing_description": "Välj ett bokföringskonto för rad {row}.", "illegal_vat_rate_title": "Ogiltig momssats", "illegal_vat_rate_description": "Rad {row} har momssats {rate} %. Tillåtna momssatser är 25, 12, 6 eller 0 %.", + "rc_account_warning": "Omvänd skattskyldighet: {count, plural, =1 {rad {rows} använder ett konto som börjar på 1 eller 6} other {raderna {rows} använder konton som börjar på 1 eller 6}}. Inköp med omvänd skattskyldighet bokförs normalt på kostnadskonton (4xxx/5xxx). Kontrollera kontovalet.", "expense_registered_title": "Utlägg registrerat", "invoice_registered_title": "Faktura registrerad", "arrival_number_label": "Ankomstnummer: {number}",