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:
co-authored by
Claude Fable 5.1
parent
c72b8bcee1
commit
51b68afc87
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user