fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255)

* fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off

A foreign supplier charging no Swedish VAT is normally omvand
skattskyldighet. With the reverse-charge switch off,
createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor
the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the
momsdeklaration takes a shape Skatteverket rejects. For a fully deductible
purchase the net moms att betala is unchanged, which is exactly why this goes
unnoticed. The form already auto-ticks reverse charge for eu_business but not
for non_eu_business, so that path slips through silently.

Adds a pure helper plus a non-blocking banner cloned from the existing
rc_account_warning block. Deliberately silent for swedish_business, where 0 %
is a genuine exemption that belongs in no ruta at all, and phrased as a
question rather than an assertion: a non-EU goods purchase cleared at customs
is legitimately 0 % without reverse charge, and pushing that user into
ticking the switch would manufacture a new wrong verifikat.

Does not add the exempt/import/other picker the issue proposes:
supplier_invoices.vat_treatment is metadata that no booking or ruta mapping
reads, and the codebase cannot book import VAT at all, so an import option
would imply ruta 50/60 were handled when they are not.

Refs #1042

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint

Review flagged that the most common foreign document a Swedish small company
sees is an invoice carrying the supplier's OWN local VAT, booked at 0 %
Swedish VAT with reverse charge correctly off. The banner fires there, and
the previous copy only offered "momsfri av annat skal, till exempel en
varuimport" as the way out, which does not describe that invoice at all: it
is not VAT-free, it carries foreign VAT.

Names both legitimate cases explicitly and says 0 % is correct in them, so
the hint cannot read as an instruction to tick reverse charge on a purchase
where that would produce a wrong verifikat. Title also narrowed to "utan
svensk moms" for the same reason.

Refs #1042

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-28 18:41:25 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent ece6eca922
commit 10a7d961f3
6 changed files with 121 additions and 0 deletions
+1
View File
@@ -631,3 +631,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] Webhook delivery latency (#1201): implemented option (a), an emit-triggered kick of the existing dispatchDueDeliveries, not option (b), an authenticated SSE stream over event_log. The kick is a new lib/webhooks/dispatch-kick.ts wired into fanOutToWebhooks plus the two routes that enqueue a delivery directly (the :test verb and the manual delivery retry), and it does NOT close #1201: the issue asks for a realtime stream for API consumers and that remains open. Three constraints shaped it. It is never awaited: eventBus.emit is awaited at ~99 call sites including journal_entry.committed, and each delivery can burn a 10 s receiver timeout, so awaiting would put a stranger's HTTP endpoint on the critical path of committing a verifikat. It is coalesced per function instance, because a bulk operation emits once per row and would otherwise schedule one claim round trip per row. Its batch size is 5 rather than the cron's 50, because this work runs on the tail of a user-facing request. The SKIP LOCKED claim keeps a kick and the cron from claiming the same row at the same moment, but that is a claim-time guarantee only: the RPC autocommits before any POST, so a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier serial loop. Delivery stays at-least-once as the public docs already promise, and the kick's batch of 5 opens a narrower window than the cron's existing batch of 50 against the same 20 s stuck threshold; an early draft of this change claimed double delivery was impossible, which was wrong and is now corrected in the code comments. Scheduling uses next/server after() with a deferred-microtask fallback outside a request scope, mirroring the enable-banking callback; the fallback must stay deferred rather than inline, or the coalescing flag clears before the next kick in the same tick can see it. No API_V1_VERSION bump: no new event types and no payload change, only latency.
[2026-07-27] Bolagsskatt add-back (#1051): sumPostedYearEndDispositions now adds back 78xx planenlig avskrivning alongside 88xx and 7533, and excludes fiscal_periods.closing_entry_id from its fetch. Shipping only this "Stage 1" half of the issue: it corrects the tax base and the periodiseringsfond 25 % cap with no migration and no displayed-figure change. The issue's other half (making /rapporter show bokslut entries by moving generateIncomeStatement to excludeFinalClosingEntry) is deliberately NOT done here: it duplicates the exclusion in the kpi_report_aggregates RPC (so it needs a migration plus a pg test), it changes displayed profit for every company that ran the bokslut flow, and it requires removing the add-back at four call sites, including the one that caused the original too-high-tax customer bug. The closing-entry exclusion is part of Stage 1 rather than a follow-up because closing verifikat do carry 78xx/88xx/7533 reversal lines on production, so without it the new add-back silently cancels itself once the year is closed. The issue's stated constraint that source_type='year_end' is load-bearing for the iXBRL RR/BR split is stale: build-input.ts and arsredovisning/build-data.ts already moved to excludeFinalClosingEntry.
[2026-07-27] Documents bucket WORM (#1208): dropped the production-only `users_delete_own_documents` DELETE policy on storage.objects and pinned the invariant with a name-agnostic pg-real test, rather than adding a storage DELETE policy for authenticated users or building the orphan-cleanup script the issue asks for. The policy existed in no migration (dashboard drift, alongside `users_read_own_documents` / `users_upload_own_documents`, which production has INSTEAD of this repo's `documents_select_own` / `documents_insert_own`) and let any user delete, with a normal browser token, the storage bytes of documents linked to posted verifikat: rakenskapsinformation under BFL 7 kap 2 §. Neither deleteDocument()'s linked-check nor block_document_deletion() reaches that far; both protect the row, and the row survives pointing at nothing. Verified reproducible against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes a legacy-layout object, with it dropped the DELETE matches zero rows. Dropping it breaks nothing because every in-app remove() on this bucket has run service-role since #1215. The two legacy read/insert policies are deliberately left alone: the Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT would make most existing documents unreadable. That is Phase C. The 326 orphan objects (49.5 MB) the issue also describes are NOT cleaned up here: irreversible deletion against 7-year-retention data is not worth 49 MB without a separate report-only pass. The test asserts no DELETE and no UPDATE policy over the bucket under ANY name, because the hole arrived under a name this repo never used.
[2026-07-27] Foreign 0 % supplier invoices (#1042): shipped as an advisory banner keyed on supplier_type + reverse_charge, NOT as the "confirm the reason for 0 % VAT (exempt vs import vs other)" picker the issue asks for. Two reasons. First, supplier_invoices.vat_treatment is pure metadata: createSupplierInvoiceRegistrationEntry branches on reverse_charge + supplier_type + per-line vat_rate and never reads it, and get_vat_declaration_totals maps rutor from journal account numbers alone, so a stored reason would change no accounting output. Second, an "import" option would be false confidence: nothing in the codebase can book import VAT (no generator emits 2615/2625/2635 or the 4545-4547 basis accounts), so offering it would imply ruta 50/60 were handled when they are not. The real defect underneath the issue is narrower and is what this fixes: a foreign supplier at 0 % with reverse charge left off books no 26x4 leg and no 44xx/45xx basis, emptying ruta 20-24/30-32/48. The check is deliberately silent for swedish_business, where 0 % is a genuine exemption belonging in no ruta, and never blocks submission, because a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge and forcing the switch there would book a wrong verifikat. Import VAT support stays a separate, larger issue.
@@ -35,6 +35,7 @@ import {
LEGAL_VAT_RATES,
findIllegalVatRateRow,
findReverseChargeAccountWarningRows,
findUnflaggedForeignZeroVatRows,
} 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'
@@ -456,6 +457,18 @@ export default function NewSupplierInvoiceForm({
? findReverseChargeAccountWarningRows(watchedItems ?? [])
: []
// Advisory nudge (#1042): a foreign supplier charging no Swedish VAT is
// normally omvand skattskyldighet. With the switch off no 26x4 leg and no
// 44xx/45xx basis is booked, so ruta 20-24, 30-32 and 48 stay empty. Silent
// for Swedish suppliers, where 0 % is a genuine exemption. Never blocks:
// a non-EU goods purchase cleared at customs is legitimately 0 % without
// reverse charge, and forcing the switch there would book a wrong verifikat.
const foreignZeroVatRows = findUnflaggedForeignZeroVatRows(
watchedItems ?? [],
watchedReverseCharge,
suppliers.find((s) => s.id === watchedSupplierId)?.supplier_type,
)
useEffect(() => {
fetchSuppliers()
fetchAccounts()
@@ -1805,6 +1818,24 @@ export default function NewSupplierInvoiceForm({
</div>
)}
{/* Non-blocking omvand-skattskyldighet hint for foreign suppliers
invoiced at 0 % VAT (#1042). Mutually exclusive with the banner
above: that one needs the switch on, this one needs it off. */}
{foreignZeroVatRows.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('foreign_zero_vat_warning', {
count: foreignZeroVatRows.length,
rows: foreignZeroVatRows.map((i) => i + 1).join(', '),
})}
</p>
</div>
)}
{/* Desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
@@ -5,6 +5,7 @@ import {
normalizeVatRateToDecimal,
findIllegalVatRateRow,
findReverseChargeAccountWarningRows,
findUnflaggedForeignZeroVatRows,
} from '@/lib/vat/supplier-invoice-line-checks'
describe('LEGAL_VAT_RATES', () => {
@@ -115,3 +116,49 @@ describe('findReverseChargeAccountWarningRows', () => {
).toEqual([0, 1])
})
})
describe('findUnflaggedForeignZeroVatRows', () => {
const zero = [{ vat_rate: 0 }]
it('flags every 0 % line on an EU supplier invoice with reverse charge off', () => {
const items = [{ vat_rate: 0 }, { vat_rate: 0.25 }, { vat_rate: 0 }]
expect(findUnflaggedForeignZeroVatRows(items, false, 'eu_business')).toEqual([0, 2])
})
it('flags a 0 % line on a non-EU supplier invoice', () => {
// The form auto-ticks reverse charge for eu_business but not for
// non_eu_business, so this is the case that actually slips through.
expect(findUnflaggedForeignZeroVatRows(zero, false, 'non_eu_business')).toEqual([0])
})
it('stays silent for a Swedish supplier at 0 %', () => {
// Bankavgift, forsakring, hyra: a genuine exemption that belongs in no
// ruta at all. Flagging it would be noise, not a finding.
expect(findUnflaggedForeignZeroVatRows(zero, false, 'swedish_business')).toEqual([])
})
it('stays silent when reverse charge is already on', () => {
const items = [{ vat_rate: 0 }, { vat_rate: 0 }]
expect(findUnflaggedForeignZeroVatRows(items, true, 'eu_business')).toEqual([])
expect(findUnflaggedForeignZeroVatRows(items, true, 'non_eu_business')).toEqual([])
})
it('stays silent before a supplier is picked', () => {
expect(findUnflaggedForeignZeroVatRows(zero, false, undefined)).toEqual([])
expect(findUnflaggedForeignZeroVatRows(zero, false, null)).toEqual([])
expect(findUnflaggedForeignZeroVatRows(zero, false, '')).toEqual([])
})
it('stays silent when the foreign supplier charged VAT on every line', () => {
const items = [{ vat_rate: 0.25 }, { vat_rate: 0.12 }, { vat_rate: 0.06 }]
expect(findUnflaggedForeignZeroVatRows(items, false, 'eu_business')).toEqual([])
})
it('returns an empty list for no items', () => {
expect(findUnflaggedForeignZeroVatRows([], false, 'eu_business')).toEqual([])
})
it('ignores an unknown supplier type rather than guessing it is foreign', () => {
expect(findUnflaggedForeignZeroVatRows(zero, false, 'private_person')).toEqual([])
})
})
+40
View File
@@ -70,3 +70,43 @@ export function findReverseChargeAccountWarningRows(
})
return rows
}
/** Supplier types whose invoices normally carry no Swedish VAT because the
* buyer self-assesses it (omvand skattskyldighet, ML 6 kap). */
const FOREIGN_SUPPLIER_TYPES: readonly string[] = ['eu_business', 'non_eu_business']
/**
* Indices of 0 %-VAT lines on a FOREIGN supplier invoice where omvand
* skattskyldighet is switched off (issue #1042).
*
* A foreign supplier charging no Swedish VAT is normally a reverse charge
* purchase: the buyer books both the utgaende and the ingaende moms itself.
* With the switch off, createSupplierInvoiceRegistrationEntry emits neither
* the 26x4 output leg nor the 44xx/45xx basis lines, so ruta 20-24, 30-32 and
* 48 all stay empty and the momsdeklaration takes the shape Skatteverket
* rejects (a ruta 30 amount with no matching ruta 20 basis). The net moms att
* betala is usually unchanged for a fully deductible purchase, which is
* exactly why this goes unnoticed.
*
* Advisory only, never blocking, and deliberately silent for
* swedish_business: a Swedish 0 % invoice (bankavgift, forsakring, hyra) is a
* genuine exemption that belongs in no box at all, and nagging about it would
* be pure noise. It is also silent when reverse charge is already on, and for
* a foreign supplier that legitimately invoiced 0 % without reverse charge
* (a non-EU goods purchase cleared at customs, or an EU seller charging its
* own local VAT), which is why the copy asks rather than asserts: pushing
* such a user into ticking the switch would manufacture a new wrong verifikat.
*/
export function findUnflaggedForeignZeroVatRows(
items: ReadonlyArray<{ vat_rate: number }>,
reverseCharge: boolean,
supplierType: string | undefined | null,
): number[] {
if (reverseCharge) return []
if (!supplierType || !FOREIGN_SUPPLIER_TYPES.includes(supplierType)) return []
const rows: number[] = []
items.forEach((item, index) => {
if (item.vat_rate === 0) rows.push(index)
})
return rows
}
+1
View File
@@ -3654,6 +3654,7 @@
"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.",
"foreign_zero_vat_warning": "Foreign supplier with no Swedish VAT: {count, plural, =1 {line {rows} has} other {lines {rows} have}} 0 % VAT while reverse charge is switched off. For a service bought from abroad, reverse charge should be on; otherwise boxes 20-24, 30-32 and 48 stay empty in the VAT return. If the supplier charged its own local VAT instead, or this is a goods import handled by customs, 0 % is correct and you can ignore this.",
"expense_registered_title": "Expense registered",
"invoice_registered_title": "Invoice registered",
"arrival_number_label": "Arrival number: {number}",
+1
View File
@@ -3654,6 +3654,7 @@
"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.",
"foreign_zero_vat_warning": "Utländsk leverantör utan svensk moms: {count, plural, =1 {rad {rows} har} other {raderna {rows} har}} 0 % moms medan omvänd skattskyldighet är avstängd. Är det ett tjänsteköp från utlandet ska omvänd skattskyldighet vara på, annars blir ruta 20-24, 30-32 och 48 tomma i momsdeklarationen. Har leverantören i stället debiterat sin egen utländska moms, eller är det en varuimport som tullen hanterar, är 0 % rätt och du kan bortse från detta.",
"expense_registered_title": "Utlägg registrerat",
"invoice_registered_title": "Faktura registrerad",
"arrival_number_label": "Ankomstnummer: {number}",