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:
Jakob Wennberg
2026-07-16 18:14:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6bd85f94b6
commit a95872928e
5 changed files with 173 additions and 7 deletions
@@ -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<HTMLInputElement>(null)
@@ -212,7 +215,7 @@ function VatRateCell({ value, onChange }: { value: number; onChange: (v: number)
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[6rem]">
{VAT_RATE_PRESETS.map((preset) => (
{LEGAL_VAT_RATES.map((preset) => (
<DropdownMenuItem
key={preset}
onSelect={() => 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({
</div>
</div>
{/* Non-blocking account-range hint for reverse charge (#863 item 2) */}
{rcAccountWarningRows.length > 0 && (
<div
role="status"
className="mb-4 flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3"
>
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="text-sm text-warning-foreground">
{t('rc_account_warning', {
count: rcAccountWarningRows.length,
rows: rcAccountWarningRows.map((i) => i + 1).join(', '),
})}
</p>
</div>
)}
{/* Desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
@@ -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])
})
})
+51
View File
@@ -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
}
+1
View File
@@ -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}",
+1
View File
@@ -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}",