Since the #1385 squash-merge every production build failed with 'Two or
more assets with different content were emitted to the same output path'
on [root-of-the-server]__1ge0sz5._.js: two distinct server chunk groups
(an AWS smithy helper chunk and the withRouteContext auth chunk) hash to
the same chunk name. The graph change that tipped the chunk layout into
the colliding state was entity-mapper.ts (arcim-migration extension
entry graph) importing lib/vat/supplier-invoice-line-checks, which
drags lib/money into the extension root.
Move normalizeVatRateToFraction into an import-free leaf module
(lib/vat/vat-rate-unit.ts), re-export it from
supplier-invoice-line-checks for all existing callers, and point
entity-mapper at the leaf. Behavior is unchanged (511 targeted tests
pass); the server chunk graph returns to the pre-#1385 shape that
builds cleanly. Verified: npm run build fails on ff864ad3d and passes
with this change.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
118 lines
5.3 KiB
TypeScript
118 lines
5.3 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'
|
|
import { normalizeVatRateToFraction } from './vat-rate-unit'
|
|
|
|
// 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)
|
|
}
|
|
|
|
// normalizeVatRateToFraction lives in the import-free leaf module
|
|
// vat-rate-unit.ts so extension entry graphs can use it without pulling in
|
|
// this file's lib/money dependency; re-exported here so existing callers
|
|
// keep their import path.
|
|
export { normalizeVatRateToFraction }
|
|
|
|
/**
|
|
* 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 {
|
|
// 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(normalizeVatRateToFraction(rate))
|
|
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
|
|
}
|