Files
accounted/lib/vat/supplier-invoice-line-checks.ts
T
Jakob WennbergandClaude Opus 5 10a7d961f3 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>
2026-07-28 18:41:25 +02:00

113 lines
5.0 KiB
TypeScript

// Pure submit-time checks for supplier invoice line items (issue #863).
// Kept free of React so the rules can be unit-tested and reused.
import { roundOre } from '@/lib/money'
// 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)
}
/**
* Normalize a VAT rate that may arrive percent-shaped (25, 12, 6: the AI
* extraction contract and stale staged pending_operations params) to the
* decimal-fraction convention used by supplier_invoice_items (0.25, 0.12,
* 0.06); issue #310. Values above 1 are treated as percent and divided by
* 100; the result is snapped to the legal Swedish set and anything else
* (foreign 19/20, non-finite or missing input) maps to 0, mirroring the
* extraction contract: the strict Swedish allowlist applies when converting
* to a supplier invoice.
*/
export function normalizeVatRateToDecimal(rate: unknown): number {
const n = Number(rate)
if (!Number.isFinite(n)) return 0
// roundOre is 2-decimal rounding: exactly the snap a decimal fraction of an
// integer percent needs (25 / 100 must land on the legal-set double).
const decimal = roundOre(n > 1 ? n / 100 : n)
return isLegalVatRate(decimal) ? decimal : 0
}
/**
* 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
}
/** 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
}