fix(ui): block illegal VAT rates, honest opening-balance state, surface account-save errors (#902)

* fix(ui): block illegal VAT rates, honest opening-balance state, surface account-save errors

- Supplier invoice form (#863 item 1): onSubmit now blocks any
  non-reverse-charge line whose VAT rate is outside the legal Swedish
  set {25, 12, 6, 0} with a destructive toast naming the line and the
  legal rates. Server-side schema tightening stays out of scope.
- Opening balance import (#837): the summary derives isBalanced from
  the running totals (0.01 epsilon) and renders the AlertCircle
  destructive pattern with the differens amount when unbalanced;
  canExecute includes isBalanced so the commit button is disabled
  instead of funneling users into a server-side rejection.
- EditAccountDialog (#838): a failed account save now shows a
  destructive toast with the server-provided message instead of being
  silently swallowed; the dialog stays open for retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): compare opening-balance totals in whole ore, map account-save errors to Swedish

Review findings on the first pass: the < 0.01 epsilon misclassified exact
1-ore imbalances as balanced (0.03 - 0.02 evaluates just under 0.01), and
the save-failure toast surfaced raw English server text instead of routing
through getErrorMessage like the sibling handlers in the same file.

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-06 11:20:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e7e7201d3
commit ac1529e413
5 changed files with 60 additions and 7 deletions
+13 -3
View File
@@ -257,14 +257,24 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte uppdatera kontot')
const data = await response.json().catch(() => null)
// Keep the dialog open so the user can correct and retry; map the
// server error to Swedish like the dimension-rule handlers above.
toast({
title: 'Kunde inte uppdatera kontot',
description: getErrorMessage(data, { locale: 'sv' }),
variant: 'destructive',
})
return
}
onSaved()
onOpenChange(false)
} catch {
// Error handled silently: toast is in parent
toast({
title: 'Kunde inte uppdatera kontot',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
+23 -4
View File
@@ -44,6 +44,11 @@ export default function OpeningBalancePeriodStep({
totalDebit = Math.round((totalDebit + row.debit_amount) * 100) / 100
totalCredit = Math.round((totalCredit + row.credit_amount) * 100) / 100
}
// Compare in whole öre, mirroring the engine's validateBalance: a float
// epsilon like (< 0.01) would misclassify exact 1-öre imbalances as
// balanced, since e.g. 0.03 - 0.02 evaluates to just under 0.01.
const balanceDiff = Math.round((totalDebit - totalCredit) * 100) / 100
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0
useEffect(() => {
async function fetchPeriods() {
@@ -83,6 +88,7 @@ export default function OpeningBalancePeriodStep({
!!selectedPeriodId &&
!periodIsClosed &&
!periodIsLocked &&
isBalanced &&
!isLoading
const handleExecute = useCallback(() => {
@@ -160,10 +166,23 @@ export default function OpeningBalancePeriodStep({
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</span>
</div>
<div className="flex items-center gap-2 pt-1 border-t text-sm">
<CheckCircle2 className="h-4 w-4 text-success" />
<span className="text-success font-medium">Balanserar</span>
</div>
{isBalanced ? (
<div className="flex items-center gap-2 pt-1 border-t text-sm">
<CheckCircle2 className="h-4 w-4 text-success" />
<span className="text-success font-medium">Balanserar</span>
</div>
) : (
<div className="flex items-center gap-2 pt-1 border-t text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-destructive font-medium">
Balanserar inte (differens{' '}
<span className="tabular-nums">
{balanceDiff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>{' '}
SEK)
</span>
</div>
)}
</div>
{/* Error */}
@@ -1059,6 +1059,26 @@ export default function NewSupplierInvoiceForm({
})
return
}
// Only 25/12/6/0 % are legal Swedish VAT rates (ML 2023:200). The free-text
// VatRateCell clamps to [0, 100] but accepts anything in between, so block
// 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),
)
if (rowWithIllegalRate !== -1) {
toast({
title: t('illegal_vat_rate_title'),
description: t('illegal_vat_rate_description', {
row: rowWithIllegalRate + 1,
rate: rateToPctString(data.items[rowWithIllegalRate].vat_rate),
}),
variant: 'destructive',
})
return
}
}
// A row with an open periodisering panel must carry a complete period of
// at least two calendar months before the invoice can be booked.
const invalidAccrual = canUseAccrual && data.items.some((item) => {
+2
View File
@@ -2889,6 +2889,8 @@
"invoice_number_missing_description": "Enter the supplier's invoice number.",
"account_missing_title": "Account missing",
"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 %.",
"expense_registered_title": "Expense registered",
"invoice_registered_title": "Invoice registered",
"arrival_number_label": "Arrival number: {number}",
+2
View File
@@ -2889,6 +2889,8 @@
"invoice_number_missing_description": "Ange leverantörens fakturanummer.",
"account_missing_title": "Konto saknas",
"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 %.",
"expense_registered_title": "Utlägg registrerat",
"invoice_registered_title": "Faktura registrerad",
"arrival_number_label": "Ankomstnummer: {number}",