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:
co-authored by
Claude Fable 5
parent
8e7e7201d3
commit
ac1529e413
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user