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
@@ -41,8 +41,10 @@ export const GET = withRouteContext<{ params: Promise<{ accountKey: string }> }>
* POST /api/reconciliation/accounts/{accountKey}/signoff
*
* "Markera som avstämd t.o.m. <datum>". Body { through_date, note?, force?,
* dry_run? }. Refused (400 + code) unless the account is reconciled through
* the date, or force + note is given.
* dry_run? }. Refused (400 + code, plus details.unexplained_difference on
* NOT_RECONCILED) unless the account is reconciled through the date, or
* force + note is given. The dialog sends dry_run first so what it shows is
* exactly what the real call will judge.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.signoff.create',
@@ -82,7 +84,15 @@ export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }
} catch (err) {
if (err instanceof ReconciliationSignoffError) {
const status = err.code === 'SIGNOFF_NOT_FOUND' ? 404 : err.code === 'SIGNOFF_RACE' ? 409 : 400
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
// The refusal text reaches the user as written (the codes are
// registered with thrown_message_sv; before that the mapper fell
// through to "Något gick fel. Försök igen."). details (the unexplained
// amount on NOT_RECONCILED) lets the dialog name the difference when
// it previews the sign-off with dry_run.
return NextResponse.json(
{ error: getErrorMessage(err), code: err.code, ...(err.details ? { details: err.details } : {}) },
{ status },
)
}
throw err
}
@@ -136,14 +136,26 @@ describe('dashboard sign-off routes', () => {
})
it('POST maps policy refusals to 400 + code, races to 409, and a null result to 404', async () => {
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('oförklarat', 'NOT_RECONCILED'))
signMock.mockRejectedValueOnce(
new ReconciliationSignoffError('oförklarat', 'NOT_RECONCILED', { unexplained_difference: 53717 }),
)
const refused = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect(refused.status).toBe(400)
const refusedBody = (await parseJsonResponse<{ code: string }>(refused)).body
const refusedBody = (await parseJsonResponse<{ code: string; error: string; details?: unknown }>(refused)).body
expect(refusedBody.code).toBe('NOT_RECONCILED')
expect(refusedBody.error).toBe('oförklarat')
// The dialog previews with dry_run and reads the amount from here.
expect(refusedBody.details).toEqual({ unexplained_difference: 53717 })
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('okänt', 'OUTSIDE_UNKNOWN'))
const unknown = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect((await parseJsonResponse<{ details?: unknown }>(unknown)).body.details).toBeUndefined()
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('race', 'SIGNOFF_RACE'))
const raced = await signPOST(
@@ -18,8 +18,8 @@ import { readV1JsonBody } from '@/lib/api/v1/body'
import { AccountKeySchema, ReconciliationSignoffSchema } from '@/lib/reconciliation/schemas'
import { listSignoffs } from '@/lib/reconciliation/signoff-store'
import { ReconciliationSignoffError, signOffAccount } from '@/lib/reconciliation/signoff'
import { ISO_DATE_RE } from '@/lib/invariants'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
const SignoffRequest = z.object({
through_date: z.string().regex(ISO_DATE_RE),
@@ -186,7 +186,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
: 'VALIDATION_ERROR'
return v1ErrorResponseFromCode(v1Code, ctx.log, {
requestId: ctx.requestId,
details: { code: err.code, message: getErrorMessage(err) },
details: { code: err.code, message: getErrorMessage(err), ...(err.details ? err.details : {}) },
})
}
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })