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
@@ -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(
|
||||
|
||||
+2
-2
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user