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