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">