Files
accounted/lib/reconciliation/__tests__/signoff.test.ts
T
ebbdf96b74 feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)
* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1)

Every class 1-2 account the bank and skattekonto adapters do not own now
appears on the Avstämning page under "Övriga balanskonton" with IB, movement
and UB through the balansdag, a system specification where one exists
(1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld)
and, for every other account, the balance the signer states from their
underlag at sign-off. Same three doors as before: dashboard routes, v1 API
and the MCP tools take manual:<BAS> keys and an external_balance.

The ledger side is computed per fiscal period via generateTrialBalance,
never as an all-history sum: year-end re-books every balance account in an
opening_balance verifikat, so an all-history sum counts a closed year twice.

A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever
the system already has an outside truth, so it can never hide a difference.

No migration: account_reconciliations already accepts manual:NNNN keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* chore(api-skill): regenerate banking reference for the sign-off external_balance field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:23:14 +02:00

397 lines
15 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('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()
})
})