c62321988b
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store 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> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
159 lines
8.5 KiB
TypeScript
159 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const listAccountsMock = vi.fn()
|
|
const readinessMock = vi.fn()
|
|
vi.mock('@/lib/reconciliation/service', () => ({
|
|
listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
|
|
}))
|
|
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
|
|
validateYearEndReadiness: (...args: unknown[]) => readinessMock(...args),
|
|
}))
|
|
|
|
import {
|
|
BOKSLUT_CHECKLIST,
|
|
assembleChecklist,
|
|
buildBokslutChecklist,
|
|
computeAutoStates,
|
|
setChecklistItem,
|
|
type ChecklistRow,
|
|
} from '../checklist'
|
|
|
|
const COMPANY = 'company-1'
|
|
const USER = 'user-1'
|
|
const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
|
|
|
|
function account(overrides: Record<string, unknown>) {
|
|
return {
|
|
account_key: 'manual:2350',
|
|
kind: 'manual',
|
|
account_number: '2350',
|
|
name: 'Banklån',
|
|
currency: 'SEK',
|
|
logo_url: null,
|
|
source: { type: 'manual', synced_at: null, stale: false },
|
|
status: { state: 'open', as_of: '2026-12-31T00:00:00.000Z', unexplained_difference: null, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } },
|
|
superseded_by: null,
|
|
signed_off_through: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
listAccountsMock.mockReset()
|
|
readinessMock.mockReset()
|
|
})
|
|
|
|
describe('catalogue', () => {
|
|
it('has unique keys that satisfy the table CHECK, in work order', () => {
|
|
const keys = BOKSLUT_CHECKLIST.map((d) => d.key)
|
|
expect(new Set(keys).size).toBe(keys.length)
|
|
for (const k of keys) expect(k).toMatch(/^[a-z0-9_]{1,64}$/)
|
|
expect(keys[0]).toBe('bank_signed')
|
|
expect(keys[keys.length - 1]).toBe('annual_accounts_reviewed')
|
|
})
|
|
})
|
|
|
|
describe('computeAutoStates', () => {
|
|
it('judges sign-offs through the balansdag, tie-outs, and readiness counts', () => {
|
|
const accounts = [
|
|
account({ account_key: 'bank:1', kind: 'bank', account_number: '1930', signed_off_through: '2026-12-31' }),
|
|
account({ account_key: 'bank:2', kind: 'bank', account_number: '1931', signed_off_through: '2026-11-30', superseded_by: 'bank:1' }),
|
|
account({ account_key: 'skattekonto', kind: 'skattekonto', account_number: '1630', signed_off_through: null }),
|
|
account({ account_key: 'manual:1510', account_number: '1510', status: { state: 'reconciled', as_of: 'x', unexplained_difference: 0, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } } }),
|
|
account({ account_key: 'manual:2350', signed_off_through: '2026-12-31' }),
|
|
account({ account_key: 'manual:2990', account_number: '2990', signed_off_through: '2026-06-30' }),
|
|
]
|
|
const states = computeAutoStates(accounts as never, { draftCount: 2, unexplainedGaps: 0, trialBalanceBalanced: true }, '2026-12-31')
|
|
expect(Object.fromEntries(states)).toEqual({
|
|
bank_signed: 'done',
|
|
skattekonto_signed: 'open',
|
|
ar_reconciled: 'done',
|
|
ap_reconciled: 'not_applicable',
|
|
balance_accounts_signed: 'open',
|
|
no_drafts: 'open',
|
|
voucher_gaps_explained: 'done',
|
|
trial_balance_balanced: 'done',
|
|
})
|
|
})
|
|
|
|
it('marks feeds the company does not have as not applicable and leaves unknown inputs out', () => {
|
|
const states = computeAutoStates([], null, '2026-12-31')
|
|
expect(states.get('bank_signed')).toBe('not_applicable')
|
|
expect(states.get('skattekonto_signed')).toBe('not_applicable')
|
|
expect(states.has('no_drafts')).toBe(false)
|
|
expect(computeAutoStates(null, null, '2026-12-31').size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('assembleChecklist', () => {
|
|
it('lets a stored row override the computed state and counts the summary', () => {
|
|
const rows: ChecklistRow[] = [
|
|
{ item_key: 'skattekonto_signed', state: 'not_applicable', note: 'Inget skattekonto kopplat', done_by: USER, done_at: '2027-01-05T08:00:00Z', updated_by: USER, updated_at: '2027-01-05T08:00:00Z' },
|
|
{ item_key: 'inventory_valued', state: 'done', note: null, done_by: USER, done_at: '2027-01-06T08:00:00Z', updated_by: USER, updated_at: '2027-01-06T08:00:00Z' },
|
|
]
|
|
const auto = new Map([['skattekonto_signed', 'open' as const], ['no_drafts', 'done' as const]])
|
|
const list = assembleChecklist(PERIOD, auto, rows)
|
|
const byKey = Object.fromEntries(list.items.map((i) => [i.key, i]))
|
|
expect(byKey.skattekonto_signed).toMatchObject({ auto_state: 'open', stored_state: 'not_applicable', effective_state: 'not_applicable', note: 'Inget skattekonto kopplat' })
|
|
expect(byKey.inventory_valued).toMatchObject({ auto_state: null, effective_state: 'done', done_at: '2027-01-06T08:00:00Z' })
|
|
expect(byKey.no_drafts).toMatchObject({ auto_state: 'done', stored_state: null, effective_state: 'done' })
|
|
expect(byKey.accruals_posted.effective_state).toBe('open')
|
|
expect(list.summary).toEqual({ total: BOKSLUT_CHECKLIST.length, done: 2, not_applicable: 1, open: BOKSLUT_CHECKLIST.length - 3 })
|
|
})
|
|
})
|
|
|
|
describe('buildBokslutChecklist', () => {
|
|
it('reads the period and rows, computes readiness when not given, and survives a failed reconciliation read', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: PERIOD })
|
|
enqueue({ data: [{ item_key: 'inventory_valued', state: 'done', note: null, done_by: USER, done_at: '2027-01-06T08:00:00Z', updated_by: USER, updated_at: '2027-01-06T08:00:00Z' }] })
|
|
listAccountsMock.mockRejectedValue(new Error('down'))
|
|
readinessMock.mockResolvedValue({ draftCount: 0, unexplainedGaps: [{ series: 'A', from: 12, to: 12 }], trialBalanceBalanced: true })
|
|
|
|
const list = await buildBokslutChecklist(supabase as never, COMPANY, USER, 'fy-2026')
|
|
expect(list?.period.id).toBe('fy-2026')
|
|
const byKey = Object.fromEntries(list!.items.map((i) => [i.key, i]))
|
|
expect(byKey.inventory_valued.effective_state).toBe('done')
|
|
expect(byKey.voucher_gaps_explained.effective_state).toBe('open')
|
|
expect(byKey.bank_signed).toMatchObject({ auto_state: null, effective_state: 'open' })
|
|
expect(listAccountsMock).toHaveBeenCalledWith(supabase, COMPANY, { today: '2026-12-31', windowFrom: '2026-01-01', windowTo: '2026-12-31' })
|
|
expect(readinessMock).toHaveBeenCalledWith(supabase, COMPANY, USER, 'fy-2026')
|
|
})
|
|
|
|
it('skips the readiness computation when told to, and returns null for a foreign period', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: PERIOD })
|
|
enqueue({ data: [] })
|
|
listAccountsMock.mockResolvedValue([])
|
|
await buildBokslutChecklist(supabase as never, COMPANY, USER, 'fy-2026', { readiness: null })
|
|
expect(readinessMock).not.toHaveBeenCalled()
|
|
|
|
enqueue({ data: null })
|
|
expect(await buildBokslutChecklist(supabase as never, COMPANY, USER, 'nope')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('setChecklistItem', () => {
|
|
it('upserts as the acting user with the done stamp, and refuses unknown items or bad states', async () => {
|
|
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: { item_key: 'inventory_valued', state: 'done', note: 'Inventerat 2026-12-30', done_by: USER, done_at: 'x', updated_by: USER, updated_at: 'x' } })
|
|
const row = await setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'inventory_valued', state: 'done', note: ' Inventerat 2026-12-30 ' })
|
|
expect(row.state).toBe('done')
|
|
const [payload, opts] = findCall('bokslut_checklist_items', 'upsert') as [Record<string, unknown>, { onConflict: string }]
|
|
expect(payload).toMatchObject({ company_id: COMPANY, fiscal_period_id: 'fy-2026', item_key: 'inventory_valued', state: 'done', note: 'Inventerat 2026-12-30', done_by: USER, updated_by: USER })
|
|
expect(payload.done_at).toBeTruthy()
|
|
expect(opts.onConflict).toBe('company_id,fiscal_period_id,item_key')
|
|
|
|
enqueue({ data: { item_key: 'inventory_valued', state: 'open', note: null, done_by: null, done_at: null, updated_by: USER, updated_at: 'x' } })
|
|
await setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'inventory_valued', state: 'open' })
|
|
const upserts = findCalls('bokslut_checklist_items', 'upsert')
|
|
const [reopened] = upserts[upserts.length - 1] as [Record<string, unknown>]
|
|
expect(reopened).toMatchObject({ done_by: null, done_at: null })
|
|
|
|
await expect(setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'nope', state: 'done' })).rejects.toMatchObject({ code: 'UNKNOWN_ITEM' })
|
|
await expect(setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'no_drafts', state: 'maybe' as never })).rejects.toMatchObject({ code: 'INVALID_STATE' })
|
|
})
|
|
})
|