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>
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const statusMock = vi.fn()
|
|
const latestMock = vi.fn()
|
|
const byIdMock = vi.fn()
|
|
const insertMock = vi.fn()
|
|
const stampMock = vi.fn()
|
|
const emitMock = vi.fn()
|
|
|
|
vi.mock('../service', () => ({
|
|
getAccountStatus: (...args: unknown[]) => statusMock(...args),
|
|
}))
|
|
vi.mock('../signoff-store', () => ({
|
|
getLatestSignoff: (...args: unknown[]) => latestMock(...args),
|
|
getSignoffById: (...args: unknown[]) => byIdMock(...args),
|
|
insertSignoff: (...args: unknown[]) => insertMock(...args),
|
|
stampReopen: (...args: unknown[]) => stampMock(...args),
|
|
}))
|
|
vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: (...args: unknown[]) => emitMock(...args) } }))
|
|
|
|
import { ReconciliationSignoffError, reopenSignoff, signOffAccount } from '../signoff'
|
|
|
|
const COMPANY = 'company-1'
|
|
const USER = 'user-1'
|
|
const TODAY = '2026-08-23'
|
|
const SIGNOFF_ID = '77777777-7777-4777-8777-777777777777'
|
|
|
|
function status(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
account_key: 'skattekonto',
|
|
kind: 'skattekonto',
|
|
account_number: '1630',
|
|
currency: 'SEK',
|
|
as_of: '2026-08-20T06:00:00Z',
|
|
stale: false,
|
|
external_balance: 1000,
|
|
ledger_balance: 1000,
|
|
difference: 0,
|
|
unexplained_difference: 0,
|
|
is_reconciled: true,
|
|
bridge: [],
|
|
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 },
|
|
skattekonto: null,
|
|
bank: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function row(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: SIGNOFF_ID,
|
|
account_key: 'skattekonto',
|
|
through_date: '2026-07-31',
|
|
external_balance: 1000,
|
|
ledger_balance: 1000,
|
|
unexplained_difference: 0,
|
|
note: null,
|
|
signed_by: USER,
|
|
signed_at: '2026-08-23T10:00:00Z',
|
|
reopened_at: null,
|
|
reopened_by: null,
|
|
reopen_reason: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('signOffAccount', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
statusMock.mockReset()
|
|
latestMock.mockReset()
|
|
insertMock.mockReset()
|
|
emitMock.mockResolvedValue(undefined)
|
|
statusMock.mockResolvedValue(status())
|
|
latestMock.mockResolvedValue(null)
|
|
insertMock.mockImplementation(async (_s: unknown, _c: unknown, input: Record<string, unknown>) =>
|
|
row({ through_date: input.through_date, note: input.note, unexplained_difference: input.unexplained_difference }),
|
|
)
|
|
})
|
|
|
|
it('returns null for an unknown account key and for a key the engine does not resolve', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
expect(await signOffAccount(supabase as never, COMPANY, USER, 'nope', { through_date: '2026-07-31' })).toBeNull()
|
|
expect(statusMock).not.toHaveBeenCalled()
|
|
statusMock.mockResolvedValue(null)
|
|
expect(
|
|
await signOffAccount(supabase as never, COMPANY, USER, 'manual:1910', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).toBeNull()
|
|
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'manual:1910', { today: TODAY, windowTo: '2026-07-31' })
|
|
})
|
|
|
|
describe('manual accounts', () => {
|
|
function manualStatus(overrides: Record<string, unknown> = {}) {
|
|
return status({
|
|
account_key: 'manual:2350',
|
|
kind: 'manual',
|
|
account_number: '2350',
|
|
as_of: '2026-07-31T00:00:00.000Z',
|
|
external_balance: null,
|
|
ledger_balance: -250000,
|
|
difference: null,
|
|
unexplained_difference: null,
|
|
is_reconciled: false,
|
|
manual: { specification: null },
|
|
...overrides,
|
|
})
|
|
}
|
|
|
|
it('signs with the stated balance when it equals the booked one, recording both', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(manualStatus())
|
|
const result = await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'manual:2350',
|
|
{ through_date: '2026-07-31', external_balance: -250000, note: 'Enligt engagemangsbesked' },
|
|
{ today: TODAY },
|
|
)
|
|
expect(result).toMatchObject({ dry_run: false })
|
|
expect(insertMock).toHaveBeenCalledWith(
|
|
supabase,
|
|
COMPANY,
|
|
expect.objectContaining({
|
|
account_key: 'manual:2350',
|
|
external_balance: -250000,
|
|
ledger_balance: -250000,
|
|
unexplained_difference: 0,
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('treats the gap between stated and booked as the unexplained difference', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(manualStatus())
|
|
await expect(
|
|
signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'manual:2350',
|
|
{ through_date: '2026-07-31', external_balance: -249500 },
|
|
{ today: TODAY },
|
|
),
|
|
).rejects.toMatchObject({ code: 'NOT_RECONCILED' })
|
|
const forced = await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'manual:2350',
|
|
{ through_date: '2026-07-31', external_balance: -249500, force: true, note: 'Amortering bokförs i augusti.' },
|
|
{ today: TODAY, dryRun: true },
|
|
)
|
|
expect(forced).toMatchObject({
|
|
dry_run: true,
|
|
would_sign: { external_balance: -249500, ledger_balance: -250000, unexplained_difference: -500, forced: true },
|
|
})
|
|
})
|
|
|
|
it('still needs force + note when no balance is stated', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(manualStatus())
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'manual:2350', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'OUTSIDE_UNKNOWN' })
|
|
})
|
|
|
|
it('refuses a stated balance where the system already has an outside truth', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(
|
|
manualStatus({
|
|
account_key: 'manual:1510',
|
|
account_number: '1510',
|
|
external_balance: 12000,
|
|
ledger_balance: 12000,
|
|
unexplained_difference: 0,
|
|
is_reconciled: true,
|
|
manual: { specification: { provider: 'ar', amount: 12000 } },
|
|
}),
|
|
)
|
|
await expect(
|
|
signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'manual:1510',
|
|
{ through_date: '2026-07-31', external_balance: 12000 },
|
|
{ today: TODAY },
|
|
),
|
|
).rejects.toMatchObject({ code: 'EXTERNAL_BALANCE_NOT_ALLOWED' })
|
|
statusMock.mockResolvedValue(status())
|
|
await expect(
|
|
signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'skattekonto',
|
|
{ through_date: '2026-07-31', external_balance: 1000 },
|
|
{ today: TODAY },
|
|
),
|
|
).rejects.toMatchObject({ code: 'EXTERNAL_BALANCE_NOT_ALLOWED' })
|
|
expect(insertMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('rejects a malformed date and a date in the future', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '31/07/2026' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'INVALID_DATE' })
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-09-01' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'DATE_IN_FUTURE' })
|
|
expect(insertMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('signs a reconciled account, records the numbers, and emits reconciliation.signed_off', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const result = await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'skattekonto',
|
|
{ through_date: '2026-07-31' },
|
|
{ today: TODAY },
|
|
)
|
|
expect(result).toMatchObject({ dry_run: false, signoff: { through_date: '2026-07-31', unexplained_difference: 0 } })
|
|
// The bridge is asked for through the requested date.
|
|
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'skattekonto', { today: TODAY, windowTo: '2026-07-31' })
|
|
expect(insertMock).toHaveBeenCalledWith(
|
|
supabase,
|
|
COMPANY,
|
|
expect.objectContaining({ account_key: 'skattekonto', through_date: '2026-07-31', signed_by: USER, note: null }),
|
|
)
|
|
expect(emitMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'reconciliation.signed_off',
|
|
payload: expect.objectContaining({ accountKey: 'skattekonto', throughDate: '2026-07-31', signoffId: SIGNOFF_ID }),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('dry run previews without writing', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
const result = await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'skattekonto',
|
|
{ through_date: '2026-07-31' },
|
|
{ today: TODAY, dryRun: true },
|
|
)
|
|
expect(result).toMatchObject({
|
|
dry_run: true,
|
|
would_sign: { through_date: '2026-07-31', is_reconciled: true, forced: false, previous_through_date: null },
|
|
})
|
|
expect(insertMock).not.toHaveBeenCalled()
|
|
expect(emitMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses an unexplained difference unless forced with a note, then records the difference', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(status({ unexplained_difference: 12.5, is_reconciled: false }))
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'NOT_RECONCILED' })
|
|
await expect(
|
|
signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'skattekonto',
|
|
{ through_date: '2026-07-31', force: true },
|
|
{ today: TODAY },
|
|
),
|
|
).rejects.toMatchObject({ code: 'NOTE_REQUIRED' })
|
|
const forced = await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'skattekonto',
|
|
{ through_date: '2026-07-31', force: true, note: 'Bankavgift 12,50 bokförs i augusti.' },
|
|
{ today: TODAY },
|
|
)
|
|
expect(forced).toMatchObject({ dry_run: false })
|
|
expect(insertMock).toHaveBeenCalledWith(
|
|
supabase,
|
|
COMPANY,
|
|
expect.objectContaining({ unexplained_difference: 12.5, note: 'Bankavgift 12,50 bokförs i augusti.' }),
|
|
)
|
|
})
|
|
|
|
it('refuses when the outside balance is unknown', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(status({ external_balance: null, unexplained_difference: null, is_reconciled: false }))
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'OUTSIDE_UNKNOWN' })
|
|
})
|
|
|
|
it('refuses a skattekonto date past the snapshot', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
statusMock.mockResolvedValue(status({ as_of: '2026-07-15T06:00:00Z' }))
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'NOT_FETCHED_THROUGH' })
|
|
})
|
|
|
|
it('refuses a date at or before the latest active sign-off', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
latestMock.mockResolvedValue(row({ through_date: '2026-07-31' }))
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'ALREADY_SIGNED_OFF' })
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-06-30' }, { today: TODAY }),
|
|
).rejects.toMatchObject({ code: 'ALREADY_SIGNED_OFF' })
|
|
// A later date is fine and the preview names what it supersedes.
|
|
const later = await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'skattekonto',
|
|
{ through_date: '2026-08-15' },
|
|
{ today: TODAY, dryRun: true },
|
|
)
|
|
expect(later).toMatchObject({ dry_run: true, would_sign: { previous_through_date: '2026-07-31' } })
|
|
})
|
|
|
|
it('maps a unique-index collision to SIGNOFF_RACE', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
insertMock.mockRejectedValue(new Error('duplicate key value violates unique constraint "ux_account_reconciliations_active"'))
|
|
await expect(
|
|
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
|
).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)
|
|
expect(
|
|
await signOffAccount(
|
|
supabase as never,
|
|
COMPANY,
|
|
USER,
|
|
'bank:11111111-1111-4111-8111-111111111111',
|
|
{ through_date: '2026-07-31' },
|
|
{ today: TODAY },
|
|
),
|
|
).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('reopenSignoff', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
byIdMock.mockReset()
|
|
stampMock.mockReset()
|
|
emitMock.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('stamps the row, emits reconciliation.reopened, and returns the updated row', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
byIdMock.mockResolvedValue(row())
|
|
stampMock.mockResolvedValue(row({ reopened_at: '2026-08-24T08:00:00Z', reopened_by: USER, reopen_reason: 'sen rad' }))
|
|
const result = await reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID, { reason: ' sen rad ' })
|
|
expect(result).toMatchObject({ reopened_by: USER, reopen_reason: 'sen rad' })
|
|
expect(stampMock).toHaveBeenCalledWith(supabase, COMPANY, SIGNOFF_ID, { reopened_by: USER, reason: 'sen rad' })
|
|
expect(emitMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'reconciliation.reopened', payload: expect.objectContaining({ signoffId: SIGNOFF_ID, reason: 'sen rad' }) }),
|
|
)
|
|
})
|
|
|
|
it('refuses a missing or already reopened sign-off, and reports a lost race', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
byIdMock.mockResolvedValue(null)
|
|
await expect(reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID)).rejects.toMatchObject({
|
|
code: 'SIGNOFF_NOT_FOUND',
|
|
})
|
|
byIdMock.mockResolvedValue(row({ reopened_at: '2026-08-24T08:00:00Z', reopened_by: USER }))
|
|
await expect(reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID)).rejects.toMatchObject({
|
|
code: 'ALREADY_REOPENED',
|
|
})
|
|
byIdMock.mockResolvedValue(row())
|
|
stampMock.mockResolvedValue(null)
|
|
await expect(reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID)).rejects.toBeInstanceOf(
|
|
ReconciliationSignoffError,
|
|
)
|
|
})
|
|
|
|
it('returns null for an unknown account key', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
expect(await reopenSignoff(supabase as never, COMPANY, USER, 'nope', SIGNOFF_ID)).toBeNull()
|
|
})
|
|
})
|