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
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { getErrorMessage } from '../get-error-message'
import { getErrorEntry } from '../structured-errors'
import { ReconciliationSignoffError, type SignoffErrorCode } from '@/lib/reconciliation/signoff'
/**
* The sign-off refusals are composed in Swedish at the throw site (a date, an
* amount). Before these codes were registered, getErrorMessage() fell through
* to its generic fallback and the dialog showed "Något gick fel. Försök igen."
* for a refused sign-off.
*/
const CODES: SignoffErrorCode[] = [
'INVALID_DATE',
'DATE_IN_FUTURE',
'NOT_FETCHED_THROUGH',
'OUTSIDE_UNKNOWN',
'NOT_RECONCILED',
'NOTE_REQUIRED',
'ALREADY_SIGNED_OFF',
'SIGNOFF_NOT_FOUND',
'ALREADY_REOPENED',
'SIGNOFF_RACE',
'EXTERNAL_BALANCE_NOT_ALLOWED',
]
describe('reconciliation sign-off codes in the error registry', () => {
it('registers every code with a Swedish and an English message', () => {
for (const code of CODES) {
const entry = getErrorEntry(code)
expect(entry, code).toBeDefined()
expect(entry?.message_sv, code).toMatch(/\S/)
expect(entry?.message_en, code).toMatch(/\S/)
expect(entry?.thrown_message_sv, code).toBe(true)
}
})
it('passes the thrown Swedish text through verbatim, runtime detail included', () => {
const err = new ReconciliationSignoffError(
'Skattekontot är hämtat t.o.m. 2026-08-20. Hämta igen innan du stämmer av ett senare datum.',
'NOT_FETCHED_THROUGH',
)
expect(getErrorMessage(err)).toBe(
'Skattekontot är hämtat t.o.m. 2026-08-20. Hämta igen innan du stämmer av ett senare datum.',
)
const refused = new ReconciliationSignoffError(
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
'NOT_RECONCILED',
{ unexplained_difference: 53717 },
)
expect(getErrorMessage(refused)).toBe(
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
)
expect(getErrorMessage(refused)).not.toMatch(/Något gick fel/)
})
it('gives English users the registry text', () => {
const err = new ReconciliationSignoffError('Kontot har en oförklarad differens.', 'NOT_RECONCILED')
expect(getErrorMessage(err, { locale: 'en' })).toBe(getErrorEntry('NOT_RECONCILED')?.message_en)
})
})
+3 -1
View File
@@ -519,9 +519,11 @@ export function getErrorMessage(
// Known codes without a dynamic branch above (e.g. CANNOT_REVERSE_STORNO)
// carry raw English engine messages: prefer the registry's Swedish
// message so no typed code surfaces English in a Swedish UI.
// A code flagged thrown_message_sv composes its Swedish text at the
// throw site (a date, an amount): that text wins over the static entry.
if (locale === 'sv' && typeof structured.code === 'string' && !isSwedishUserMessage(structured.message)) {
const entry = getErrorEntry(structured.code)
if (entry?.message_sv) return entry.message_sv
if (entry?.message_sv && !entry.thrown_message_sv) return entry.message_sv
}
return structured.message
}
+87
View File
@@ -35,6 +35,14 @@ export interface StructuredErrorEntry {
* locked) MUST stay false: retrying won't change the outcome.
*/
retryable?: boolean
/**
* When true, the thrower composes the Swedish message at runtime (a date,
* an amount) and getErrorMessage() passes that message through verbatim;
* message_sv is only the static fallback for an envelope that carries no
* message. Without this flag a registered code always resolves to
* message_sv, which would drop the runtime detail.
*/
thrown_message_sv?: boolean
}
// ─────────────────────────────────────────────────────────────────
@@ -4316,6 +4324,84 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
},
}
// ─────────────────────────────────────────────────────────────────
// Reconciliation sign-off (lib/reconciliation/signoff.ts)
// ─────────────────────────────────────────────────────────────────
// Policy refusals from signOffAccount / reopenSignoff. Shipped as-is on the
// dashboard, v1 and MCP surfaces before this registry knew them, so the code
// names stay. The thrower's Swedish text is the message (thrown_message_sv):
// before that, getErrorMessage() fell through to its generic fallback and the
// user read "Något gick fel. Försök igen." for a refused sign-off.
const RECONCILIATION_SIGNOFF: Record<string, StructuredErrorEntry> = {
INVALID_DATE: {
httpStatus: 400,
message_sv: 'Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.',
message_en: 'Invalid date. Use YYYY-MM-DD.',
thrown_message_sv: true,
},
DATE_IN_FUTURE: {
httpStatus: 400,
message_sv: 'Du kan inte stämma av framåt i tiden.',
message_en: 'The through date cannot be in the future.',
thrown_message_sv: true,
},
NOT_FETCHED_THROUGH: {
httpStatus: 400,
message_sv: 'Skattekontot är inte hämtat t.o.m. det datumet. Hämta igen innan du stämmer av ett senare datum.',
message_en: 'The skattekonto has not been fetched through that date. Fetch it again before signing off a later date.',
thrown_message_sv: true,
},
OUTSIDE_UNKNOWN: {
httpStatus: 400,
message_sv: 'Saldot utanför bokföringen är okänt, så kontot kan inte stämmas av. Hämta det först, eller signera med en notering.',
message_en: 'The outside balance is unknown, so the account cannot be reconciled. Fetch it first, or sign with force and a note.',
thrown_message_sv: true,
},
NOT_RECONCILED: {
httpStatus: 400,
message_sv: 'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
message_en: 'The account has an unexplained difference. Link or book the rows first, or sign with force and a note.',
thrown_message_sv: true,
},
NOTE_REQUIRED: {
httpStatus: 400,
message_sv: 'Skriv en rad om varför du signerar trots att allt inte är förklarat.',
message_en: 'A note is required when signing with force.',
thrown_message_sv: true,
},
ALREADY_SIGNED_OFF: {
httpStatus: 409,
message_sv: 'Kontot är redan avstämt t.o.m. ett senare datum. Öppna den signeringen igen om du vill ändra.',
message_en: 'The account is already signed off through that date or later. Reopen that sign-off to change it.',
thrown_message_sv: true,
},
SIGNOFF_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Signeringen hittades inte.',
message_en: 'The sign-off was not found.',
thrown_message_sv: true,
},
ALREADY_REOPENED: {
httpStatus: 409,
message_sv: 'Signeringen är redan öppnad igen.',
message_en: 'The sign-off is already reopened.',
thrown_message_sv: true,
},
SIGNOFF_RACE: {
httpStatus: 409,
message_sv: 'Kontot signerades precis av någon annan. Ladda om.',
message_en: 'Someone else just changed this sign-off. Reload and try again.',
thrown_message_sv: true,
},
EXTERNAL_BALANCE_NOT_ALLOWED: {
httpStatus: 400,
message_sv: 'Kontot har redan en sanning utanför bokföringen (bank, Skatteverket, reskontra eller beräkning). Ange inget saldo manuellt; signera med en notering om något avviker.',
message_en: 'The account already has an outside truth (bank, Skatteverket, ledger or calculation). Do not state a balance; sign with a note if something differs.',
thrown_message_sv: true,
},
}
const NODE_SYSTEM: Record<string, StructuredErrorEntry> = {
ECONNREFUSED: NETWORK_TRANSIENT_ENTRY,
ECONNRESET: NETWORK_TRANSIENT_ENTRY,
@@ -4373,6 +4459,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...ASSETS,
...DIMENSION,
...WEBSHOP_ORDERS,
...RECONCILIATION_SIGNOFF,
...NODE_SYSTEM,
}
@@ -336,6 +336,55 @@ describe('signOffAccount', () => {
).rejects.toMatchObject({ code: 'SIGNOFF_RACE' })
})
it('judges a bank account from the start of the fiscal period that covers the date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// A September-to-August company: judging from 1 January would drop the
// opening balance and the autumn movements the page window includes.
enqueue({ data: { period_start: '2025-09-01' } })
statusMock.mockResolvedValue(
status({ account_key: 'bank:11111111-1111-4111-8111-111111111111', kind: 'bank', account_number: '1930', as_of: '2026-09-02T10:00:00Z' }),
)
await signOffAccount(
supabase as never,
COMPANY,
USER,
'bank:11111111-1111-4111-8111-111111111111',
{ through_date: '2026-08-31' },
{ today: '2026-09-02' },
)
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'bank:11111111-1111-4111-8111-111111111111', {
today: '2026-09-02',
windowFrom: '2025-09-01',
windowTo: '2026-08-31',
})
})
it('leaves the window default alone when no fiscal period covers the date, and never for non-bank keys', async () => {
const { supabase } = createQueuedMockSupabase()
await signOffAccount(
supabase as never,
COMPANY,
USER,
'bank:11111111-1111-4111-8111-111111111111',
{ through_date: '2026-07-31' },
{ today: TODAY },
)
expect(statusMock).toHaveBeenLastCalledWith(supabase, COMPANY, 'bank:11111111-1111-4111-8111-111111111111', {
today: TODAY,
windowTo: '2026-07-31',
})
await signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY })
expect(statusMock).toHaveBeenLastCalledWith(supabase, COMPANY, 'skattekonto', { today: TODAY, windowTo: '2026-07-31' })
})
it('names the unexplained amount on a NOT_RECONCILED refusal so a dialog can show it', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(status({ unexplained_difference: 53717, is_reconciled: false }))
await expect(
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
).rejects.toMatchObject({ code: 'NOT_RECONCILED', details: { unexplained_difference: 53717 } })
})
it('404s (null) when the status says the account does not exist for the company', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(null)
+48 -2
View File
@@ -35,12 +35,19 @@ export type SignoffErrorCode =
| 'SIGNOFF_RACE'
| 'EXTERNAL_BALANCE_NOT_ALLOWED'
export interface SignoffErrorDetails {
/** NOT_RECONCILED: the unexplained difference the engine saw for the window, so a dialog can name the amount. */
unexplained_difference?: number | null
}
export class ReconciliationSignoffError extends Error {
readonly code: SignoffErrorCode
constructor(message: string, code: SignoffErrorCode) {
readonly details: SignoffErrorDetails | null
constructor(message: string, code: SignoffErrorCode, details: SignoffErrorDetails | null = null) {
super(message)
this.name = 'ReconciliationSignoffError'
this.code = code
this.details = details
}
}
@@ -82,6 +89,40 @@ export type SignoffResult =
| { dry_run: true; would_sign: SignoffPreview }
| { dry_run: false; signoff: ReconciliationSignoff }
/**
* The start of the fiscal period that covers `throughDate`, or null when no
* period does (or the read fails). The bank bridge is a period movement, so
* its lower bound decides the verdict: without this the sign-off judged the
* calendar year from 1 January, while the page the signer looked at was
* scoped to the fiscal period. A company with a broken fiscal year (September
* to August) then saw the dialog allow a sign-off the server refused.
*/
async function fiscalPeriodStartFor(
supabase: SupabaseClient,
companyId: string,
throughDate: string,
): Promise<string | null> {
const { data, error } = await supabase
.from('fiscal_periods')
.select('period_start')
.eq('company_id', companyId)
.lte('period_start', throughDate)
.gte('period_end', throughDate)
.order('period_start', { ascending: false })
.limit(1)
.maybeSingle()
if (error) {
log.warn('fiscal period lookup failed for sign-off window', {
companyId,
throughDate,
error: error.message,
})
return null
}
const start = (data as { period_start?: string | null } | null)?.period_start
return typeof start === 'string' && ISO_DATE_RE.test(start) ? start : null
}
/**
* Sign one account off through a date. Returns null when the account key does
* not resolve for this company (callers map that to 404); throws
@@ -116,10 +157,14 @@ export async function signOffAccount(
// The engine's view through the requested date. The skattekonto bridge is
// anchored at the saldo snapshot, so the date cannot pass it; the bank
// bridge is a period movement, so the window simply ends on the date.
// bridge is a period movement, so the window runs from the start of the
// fiscal period that covers the date (the opening balance) to the date. A
// manual account is a balance and ignores the lower bound.
const windowFrom = parsed.kind === 'bank' ? await fiscalPeriodStartFor(supabase, companyId, throughDate) : null
const status: ReconciliationStatus | null = await getAccountStatus(supabase, companyId, accountKey, {
today,
windowTo: throughDate,
...(windowFrom ? { windowFrom } : {}),
})
if (!status) return null
const asOfDate = status.as_of.slice(0, 10)
@@ -160,6 +205,7 @@ export async function signOffAccount(
throw new ReconciliationSignoffError(
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
'NOT_RECONCILED',
{ unexplained_difference: unexplained },
)
}