fix(reconciliation): judge bank sign-off from the fiscal period start and show the refusal (#2200)

A user with a September-to-August fiscal year could not sign off 1930:
the dialog let them press Signera, the server refused, and the dialog
showed "Något gick fel. Försök igen."

Three defects, one flow:

- signOffAccount judged a bank account over the calendar year from
  1 January (the getAccountStatus default) while the page the signer
  looked at was scoped to the fiscal period. The sign-off now resolves
  the fiscal period covering through_date and judges from its start,
  for every caller (dashboard, v1, MCP, pending-operation executor).
- The dialog decided whether the "sign anyway" override was needed from
  the page tile, which can be scoped to a narrower range. It now
  previews the exact sign-off with dry_run on open and on every date
  change, and NOT_RECONCILED carries the unexplained amount in
  details so the warning can name it.
- The routes passed the refusal through getErrorMessage(), which did
  not know the sign-off codes and replaced the Swedish text with its
  generic fallback. The codes are now in the structured error registry
  with a thrown_message_sv flag: the mapper passes the thrower's text
  (dates, amounts) through verbatim and English users get message_en.


Claude-Session: https://claude.ai/code/session_01YDH3nqA8QtMA8WKTKZCMsA

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 23:11:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c72b8bcee1
commit 51b68afc87
13 changed files with 390 additions and 25 deletions
+37 -3
View File
@@ -21,7 +21,7 @@ import type {
ReconciliationStatus,
} from '@/lib/reconciliation/schemas'
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog'
import { SignoffDialog, type SignoffPreviewResult, type SignoffSubmitInput } from './SignoffDialog'
import { ReconciliationUnderlag } from './ReconciliationUnderlag'
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
import { InfoTooltip } from '@/components/ui/info-tooltip'
@@ -256,14 +256,47 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
}
}
// A policy refusal carries a code and a Swedish reason written for the
// signer; show that reason as-is. The generic mapper does not recognise it
// and used to replace it with a "request contains invalid data" string.
function signoffRefusal(json: Record<string, unknown>, statusCode: number): string {
return typeof json.code === 'string' && typeof json.error === 'string' && json.error.trim()
? json.error
: getUserErrorMessage(json, { statusCode })
}
// The same call with dry_run: the server judges the exact sign-off (its own
// window, from the fiscal period start to the date) and the dialog shows
// that verdict instead of the page tile's number, which is scoped to the
// period or range picked above and can differ.
async function previewSignoff(input: SignoffSubmitInput): Promise<SignoffPreviewResult> {
const res = await fetch(`${base}/signoff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...input, dry_run: true }),
})
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (res.ok) {
const preview = (json.data as { would_sign?: { unexplained_difference?: number | null } } | undefined)?.would_sign
return { kind: 'ok', unexplained: preview?.unexplained_difference ?? null }
}
const code = typeof json.code === 'string' ? json.code : null
if (code === 'NOT_RECONCILED') {
const details = json.details as { unexplained_difference?: number | null } | undefined
return { kind: 'needs_force', unexplained: details?.unexplained_difference ?? null }
}
if (code === 'OUTSIDE_UNKNOWN') return { kind: 'needs_force', unexplained: null }
return { kind: 'blocked', message: signoffRefusal(json, res.status) }
}
async function submitSignoff(input: SignoffSubmitInput) {
const res = await fetch(`${base}/signoff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) return getUserErrorMessage(json, { statusCode: res.status })
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) return signoffRefusal(json, res.status)
setSignoffOpen(false)
toast({ title: t('toast_signed_off', { date: formatDate(input.through_date) }) })
await refresh()
@@ -805,6 +838,7 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
currency={currency}
askExternalBalance={isManual && !specification}
ledgerBalance={status.ledger_balance}
onPreview={previewSignoff}
onSubmit={submitSignoff}
/>
</div>
+74 -12
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -26,6 +26,13 @@ import { roundOre } from '@/lib/money'
* the booked balance is then what needs explaining. The policy lives in
* lib/reconciliation/signoff.ts; this dialog only collects the input and
* shows the server's refusal verbatim.
*
* Whether the override is needed comes from the server, not from the page
* tile: on open and on every date change the dialog previews the exact
* sign-off (dry run), because the server judges its own window (fiscal
* period start to the date) while the tile is scoped to whatever period or
* range the page has picked. Judging from the tile let the signer press
* Signera on a sign-off the server then refused.
*/
export interface SignoffSubmitInput {
through_date: string
@@ -34,6 +41,14 @@ export interface SignoffSubmitInput {
external_balance?: number | null
}
export type SignoffPreviewResult =
/** The server would sign as-is; `unexplained` is what it would record. */
| { kind: 'ok'; unexplained: number | null }
/** The server refuses without the override: an unexplained difference (null = the outside balance is unknown). */
| { kind: 'needs_force'; unexplained: number | null }
/** Refused for a reason the override does not lift (already signed through a later date, date in the future, ...). */
| { kind: 'blocked'; message: string }
interface SignoffDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -42,12 +57,15 @@ interface SignoffDialogProps {
defaultDate: string
/** Latest possible date (today, or the skattekonto snapshot date). */
maxDate: string
/** The page tile's number: the starting point until the preview answers. */
unexplained: number | null
currency: string
/** Ask for the balance per underlag (manual accounts without a system specification). */
askExternalBalance?: boolean
/** The booked balance the stated one is compared with. */
ledgerBalance?: number | null
/** Dry-run the sign-off for a date; the verdict decides whether the override is asked for. */
onPreview?: (input: SignoffSubmitInput) => Promise<SignoffPreviewResult>
/** Returns an error message to show inline, or null on success. */
onSubmit: (input: SignoffSubmitInput) => Promise<string | null>
}
@@ -69,6 +87,7 @@ export function SignoffDialog({
currency,
askExternalBalance = false,
ledgerBalance = null,
onPreview,
onSubmit,
}: SignoffDialogProps) {
const t = useTranslations('reconciliation')
@@ -78,13 +97,15 @@ export function SignoffDialog({
const [external, setExternal] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// With a stated balance the difference is against the booked balance;
// without one the engine's number (or "unknown") decides.
const stated = askExternalBalance ? parseAmount(external) : null
const effectiveUnexplained =
stated != null && ledgerBalance != null ? roundOre(ledgerBalance - stated) : unexplained
const needsForce = effectiveUnexplained == null || Math.abs(effectiveUnexplained) >= 0.005
const [preview, setPreview] = useState<SignoffPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Only the latest preview may land: a quick date edit fires two requests
// and the slower one must not overwrite the newer verdict.
const previewSeq = useRef(0)
// The parent passes a fresh function each render; reading it through a ref
// keeps the preview effect keyed on the date alone.
const onPreviewRef = useRef(onPreview)
onPreviewRef.current = onPreview
// Reset per opening so a second sign-off does not inherit the last one's
// note or override choice.
@@ -95,10 +116,50 @@ export function SignoffDialog({
setForce(false)
setExternal('')
setError(null)
setPreview(null)
}
}, [open, defaultDate])
const canSubmit = !busy && date.length === 10 && (!needsForce || (force && note.trim().length > 0))
// Ask the server what it would do with this exact date. A manual account
// that takes a stated balance is judged locally against the booked balance
// below, so it is not previewed (the preview would only say "unknown").
useEffect(() => {
const previewFn = onPreviewRef.current
if (!open || !previewFn || askExternalBalance || date.length !== 10) return
const seq = ++previewSeq.current
setPreviewing(true)
previewFn({ through_date: date, note: null, force: false })
.then((result) => {
if (seq === previewSeq.current) setPreview(result)
})
.catch(() => {
// Network failure: fall back to the tile's number rather than block.
if (seq === previewSeq.current) setPreview(null)
})
.finally(() => {
if (seq === previewSeq.current) setPreviewing(false)
})
}, [open, date, askExternalBalance])
// With a stated balance the difference is against the booked balance;
// otherwise the server's preview decides, and until it has answered the
// engine's number from the page tile (or "unknown") stands in.
const stated = askExternalBalance ? parseAmount(external) : null
const blocked = preview?.kind === 'blocked' ? preview.message : null
const previewUnexplained = preview && preview.kind !== 'blocked' ? preview.unexplained : unexplained
const effectiveUnexplained =
stated != null && ledgerBalance != null ? roundOre(ledgerBalance - stated) : previewUnexplained
const needsForce =
preview?.kind === 'ok' && stated == null
? false
: effectiveUnexplained == null || Math.abs(effectiveUnexplained) >= 0.005
const canSubmit =
!busy &&
!previewing &&
blocked === null &&
date.length === 10 &&
(!needsForce || (force && note.trim().length > 0))
async function submit() {
setBusy(true)
@@ -134,6 +195,7 @@ export function SignoffDialog({
onChange={(e) => setDate(e.target.value)}
className="tabular-nums"
/>
{previewing && <p className="text-[12px] text-muted-foreground">{t('signoff_preview_pending')}</p>}
</div>
{askExternalBalance && (
<div className="space-y-1.5">
@@ -153,7 +215,7 @@ export function SignoffDialog({
</p>
</div>
)}
{needsForce && (
{blocked === null && needsForce && (
<div className="space-y-2 rounded-lg bg-warning/10 px-3 py-2.5 text-[13px] text-foreground">
<p>
{effectiveUnexplained == null
@@ -178,9 +240,9 @@ export function SignoffDialog({
rows={3}
/>
</div>
{error && (
{(error ?? blocked) && (
<p role="alert" className="text-[13px] text-destructive">
{error}
{error ?? blocked}
</p>
)}
</div>