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
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user