feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -0,0 +1,158 @@
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { listReconciliationAccounts } from '@/lib/reconciliation/service'
|
||||
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
|
||||
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
|
||||
|
||||
const log = createLogger('bokslut/checklist')
|
||||
|
||||
/**
|
||||
* The bokslut checklist: the closing steps a redovisningskonsult documents
|
||||
* per räkenskapsår (Reko 760/765), as a fixed catalogue in code with one
|
||||
* state row per item in bokslut_checklist_items. Steps the system can judge
|
||||
* (drafts, voucher gaps, trial balance, sign-offs through balansdagen, the
|
||||
* reskontra tie-outs) are computed live every time; a stored row overrides
|
||||
* them (typically "ej tillämpligt") and records the manual ones. The order
|
||||
* below is the order of the work.
|
||||
*/
|
||||
|
||||
export type ChecklistState = 'open' | 'done' | 'not_applicable'
|
||||
export type ChecklistGroup = 'avstamning' | 'periodisering' | 'vardering' | 'dispositioner' | 'kontroll' | 'rapportering'
|
||||
|
||||
export interface ChecklistItemDef {
|
||||
key: string
|
||||
group: ChecklistGroup
|
||||
label_sv: string
|
||||
label_en: string
|
||||
hint_sv?: string
|
||||
hint_en?: string
|
||||
/** True when the system computes the state itself; a stored row still overrides. */
|
||||
auto: boolean
|
||||
/** Where the work is done. */
|
||||
href?: string
|
||||
}
|
||||
|
||||
export const BOKSLUT_CHECKLIST: readonly ChecklistItemDef[] = [
|
||||
{ key: 'bank_signed', group: 'avstamning', label_sv: 'Bankkonton avstämda och signerade per balansdagen', label_en: 'Bank accounts reconciled and signed off as of the balance sheet date', auto: true, href: '/reconciliation' },
|
||||
{ key: 'skattekonto_signed', group: 'avstamning', label_sv: 'Skattekontot avstämt mot Skatteverket och signerat', label_en: 'Tax account reconciled against Skatteverket and signed off', auto: true, href: '/reconciliation?account=skattekonto' },
|
||||
{ key: 'ar_reconciled', group: 'avstamning', label_sv: 'Kundfordringar stämda mot kundreskontran', label_en: 'Receivables agree with the customer ledger', auto: true, href: '/reconciliation?account=manual%3A1510' },
|
||||
{ key: 'ap_reconciled', group: 'avstamning', label_sv: 'Leverantörsskulder stämda mot leverantörsreskontran', label_en: 'Payables agree with the supplier ledger', auto: true, href: '/reconciliation?account=manual%3A2440' },
|
||||
{ key: 'balance_accounts_signed', group: 'avstamning', label_sv: 'Övriga balanskonton avstämda mot underlag och signerade', label_en: 'Other balance sheet accounts reconciled against documents and signed off', auto: true, href: '/reconciliation' },
|
||||
{ key: 'underlag_attached', group: 'avstamning', label_sv: 'Underlag bifogat till avstämningarna (kontoutdrag, engagemangsbesked, reskontralistor)', label_en: 'Supporting documents attached to the reconciliations (statements, engagement letters, ledger lists)', auto: false, href: '/reconciliation' },
|
||||
{ key: 'vat_settled', group: 'avstamning', label_sv: 'Momskontona avstämda mot lämnade deklarationer', label_en: 'VAT accounts agree with the filed returns', auto: false, href: '/reports/vat-declaration' },
|
||||
{ key: 'accruals_posted', group: 'periodisering', label_sv: 'Periodiseringar bokförda (förutbetalda och upplupna poster)', label_en: 'Accruals and deferrals posted', auto: false, href: '/bookkeeping/year-end/periodisering' },
|
||||
{ key: 'vacation_liability', group: 'periodisering', label_sv: 'Semesterlöneskuld och sociala avgifter på den avstämda', label_en: 'Vacation liability and its social fees reconciled', auto: false, href: '/reconciliation?account=manual%3A2920' },
|
||||
{ key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager inventerat och värderat (LVP)', label_en: 'Inventory counted and valued (lower of cost or market)', auto: false },
|
||||
{ key: 'doubtful_receivables', group: 'vardering', label_sv: 'Osäkra kundfordringar bedömda och nedskrivna vid behov', label_en: 'Doubtful receivables assessed and written down where needed', auto: false },
|
||||
{ key: 'depreciation_posted', group: 'vardering', label_sv: 'Avskrivningar bokförda enligt anläggningsregistret', label_en: 'Depreciation posted per the fixed asset register', auto: false, href: '/bookkeeping/year-end' },
|
||||
{ key: 'dispositions_posted', group: 'dispositioner', label_sv: 'Bokslutsdispositioner bokförda (periodiseringsfond, överavskrivningar)', label_en: 'Appropriations posted (tax allocation reserve, excess depreciation)', auto: false, href: '/bookkeeping/year-end' },
|
||||
{ key: 'tax_provision', group: 'dispositioner', label_sv: 'Årets skatt beräknad och bokförd', label_en: 'Current tax calculated and posted', auto: false, href: '/bookkeeping/year-end' },
|
||||
{ key: 'no_drafts', group: 'kontroll', label_sv: 'Inga utkast kvar i perioden', label_en: 'No draft entries left in the period', auto: true, href: '/bookkeeping?status=draft' },
|
||||
{ key: 'voucher_gaps_explained', group: 'kontroll', label_sv: 'Luckor i verifikationsnummerserien förklarade', label_en: 'Voucher number gaps explained', auto: true },
|
||||
{ key: 'trial_balance_balanced', group: 'kontroll', label_sv: 'Saldobalansen balanserar', label_en: 'The trial balance balances', auto: true, href: '/reports/saldobalans' },
|
||||
{ key: 'annual_accounts_reviewed', group: 'rapportering', label_sv: 'Årsbokslut eller årsredovisning upprättat och granskat', label_en: 'Annual accounts or annual report prepared and reviewed', auto: false, href: '/bookkeeping/year-end/arsredovisning' },
|
||||
]
|
||||
|
||||
export interface ChecklistRow {
|
||||
item_key: string
|
||||
state: ChecklistState
|
||||
note: string | null
|
||||
done_by: string | null
|
||||
done_at: string | null
|
||||
updated_by: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ChecklistItem extends ChecklistItemDef {
|
||||
/** What the system computes, null for manual items or when it could not be computed. */
|
||||
auto_state: ChecklistState | null
|
||||
/** The stored row's state, null when nobody has touched the item. */
|
||||
stored_state: ChecklistState | null
|
||||
/** stored_state, else auto_state, else open. */
|
||||
effective_state: ChecklistState
|
||||
note: string | null
|
||||
done_by: string | null
|
||||
done_at: string | null
|
||||
}
|
||||
|
||||
export interface BokslutChecklist {
|
||||
period: { id: string; name: string; period_start: string; period_end: string }
|
||||
items: ChecklistItem[]
|
||||
summary: { total: number; done: number; not_applicable: number; open: number }
|
||||
}
|
||||
|
||||
/** The subset of the readiness validation the auto items read. */
|
||||
export interface ChecklistReadinessInput {
|
||||
draftCount: number
|
||||
unexplainedGaps: number
|
||||
trialBalanceBalanced: boolean
|
||||
}
|
||||
|
||||
interface PeriodRow {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
function allSigned(accounts: ReconciliationAccount[], through: string): ChecklistState {
|
||||
if (accounts.length === 0) return 'not_applicable'
|
||||
return accounts.every((a) => a.signed_off_through != null && a.signed_off_through >= through) ? 'done' : 'open'
|
||||
}
|
||||
|
||||
function tieOut(accounts: ReconciliationAccount[], accountNumber: string, through: string): ChecklistState {
|
||||
const account = accounts.find((a) => a.kind === 'manual' && a.account_number === accountNumber)
|
||||
if (!account) return 'not_applicable'
|
||||
if (account.signed_off_through != null && account.signed_off_through >= through) return 'done'
|
||||
return account.status?.state === 'reconciled' ? 'done' : 'open'
|
||||
}
|
||||
|
||||
/** Auto states from the reconciliation list and the readiness counts; pure so it is testable without a client. */
|
||||
export function computeAutoStates(
|
||||
accounts: ReconciliationAccount[] | null,
|
||||
readiness: ChecklistReadinessInput | null,
|
||||
periodEnd: string,
|
||||
): Map<string, ChecklistState> {
|
||||
const out = new Map<string, ChecklistState>()
|
||||
if (accounts) {
|
||||
out.set('bank_signed', allSigned(accounts.filter((a) => a.kind === 'bank' && !a.superseded_by), periodEnd))
|
||||
out.set('skattekonto_signed', allSigned(accounts.filter((a) => a.kind === 'skattekonto'), periodEnd))
|
||||
out.set('ar_reconciled', tieOut(accounts, '1510', periodEnd))
|
||||
out.set('ap_reconciled', tieOut(accounts, '2440', periodEnd))
|
||||
out.set('balance_accounts_signed', allSigned(accounts.filter((a) => a.kind === 'manual'), periodEnd))
|
||||
}
|
||||
if (readiness) {
|
||||
out.set('no_drafts', readiness.draftCount === 0 ? 'done' : 'open')
|
||||
out.set('voucher_gaps_explained', readiness.unexplainedGaps === 0 ? 'done' : 'open')
|
||||
out.set('trial_balance_balanced', readiness.trialBalanceBalanced ? 'done' : 'open')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Merge catalogue, auto states and stored rows into the checklist; pure. */
|
||||
export function assembleChecklist(
|
||||
period: PeriodRow,
|
||||
autoStates: Map<string, ChecklistState>,
|
||||
rows: ChecklistRow[],
|
||||
): BokslutChecklist {
|
||||
const byKey = new Map(rows.map((r) => [r.item_key, r]))
|
||||
const items: ChecklistItem[] = BOKSLUT_CHECKLIST.map((def) => {
|
||||
const row = byKey.get(def.key)
|
||||
const auto = def.auto ? (autoStates.get(def.key) ?? null) : null
|
||||
return {
|
||||
...def,
|
||||
auto_state: auto,
|
||||
stored_state: row?.state ?? null,
|
||||
effective_state: row?.state ?? auto ?? 'open',
|
||||
note: row?.note ?? null,
|
||||
done_by: row?.done_by ?? null,
|
||||
done_at: row?.done_at ?? null,
|
||||
}
|
||||
})
|
||||
const summary = {
|
||||
total: items.length,
|
||||
done: items.filter((i) => i.effective_state === 'done').length,
|
||||
not_applicable: items.filter((i) => i.effective_state === 'not_applicable').length,
|
||||
open: items.filter((i) => i.effective_state === 'open').length,
|
||||
}
|
||||
return { period, items, summary }
|
||||
}
|
||||
|
||||
export interface BuildChecklistOptions {
|
||||
/** Pass the wizard's validation to avoid recomputing it; computed when absent. */
|
||||
readiness?: ChecklistReadinessInput | null
|
||||
}
|
||||
|
||||
/** The checklist for one period, or null when the period is not this company's. */
|
||||
export async function buildBokslutChecklist(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
fiscalPeriodId: string,
|
||||
options: BuildChecklistOptions = {},
|
||||
): Promise<BokslutChecklist | null> {
|
||||
const { data: periodData, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (periodError) throw new Error(`Kunde inte hämta räkenskapsår: ${periodError.message}`)
|
||||
const period = periodData as PeriodRow | null
|
||||
if (!period) return null
|
||||
|
||||
const { data: rowData, error: rowError } = await supabase
|
||||
.from('bokslut_checklist_items')
|
||||
.select('item_key, state, note, done_by, done_at, updated_by, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
if (rowError) throw new Error(`Kunde inte hämta bokslutschecklistan: ${rowError.message}`)
|
||||
const rows = (rowData ?? []) as ChecklistRow[]
|
||||
|
||||
// The live inputs are advisory: a failed read leaves the auto items without
|
||||
// a computed state rather than hiding the checklist.
|
||||
let accounts: ReconciliationAccount[] | null = null
|
||||
try {
|
||||
accounts = await listReconciliationAccounts(supabase, companyId, {
|
||||
today: period.period_end,
|
||||
windowFrom: period.period_start,
|
||||
windowTo: period.period_end,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('reconciliation accounts unavailable for checklist', { companyId, fiscalPeriodId, error: String(err) })
|
||||
}
|
||||
let readiness: ChecklistReadinessInput | null = options.readiness ?? null
|
||||
if (readiness === undefined || readiness === null) {
|
||||
if (options.readiness === undefined) {
|
||||
try {
|
||||
const v = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
|
||||
readiness = { draftCount: v.draftCount, unexplainedGaps: v.unexplainedGaps.length, trialBalanceBalanced: v.trialBalanceBalanced }
|
||||
} catch (err) {
|
||||
log.warn('readiness unavailable for checklist', { companyId, fiscalPeriodId, error: String(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return assembleChecklist(period, computeAutoStates(accounts, readiness, period.period_end), rows)
|
||||
}
|
||||
|
||||
export type ChecklistErrorCode = 'UNKNOWN_ITEM' | 'INVALID_STATE' | 'NOTE_TOO_LONG'
|
||||
|
||||
export class BokslutChecklistError extends Error {
|
||||
readonly code: ChecklistErrorCode
|
||||
constructor(message: string, code: ChecklistErrorCode) {
|
||||
super(message)
|
||||
this.name = 'BokslutChecklistError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface SetChecklistItemInput {
|
||||
item_key: string
|
||||
state: ChecklistState
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
/** Upsert one item's state as the acting user; returns the stored row. */
|
||||
export async function setChecklistItem(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
fiscalPeriodId: string,
|
||||
input: SetChecklistItemInput,
|
||||
): Promise<ChecklistRow> {
|
||||
if (!BOKSLUT_CHECKLIST.some((d) => d.key === input.item_key)) {
|
||||
throw new BokslutChecklistError('Okänt steg i checklistan.', 'UNKNOWN_ITEM')
|
||||
}
|
||||
if (!['open', 'done', 'not_applicable'].includes(input.state)) {
|
||||
throw new BokslutChecklistError('Ogiltigt läge.', 'INVALID_STATE')
|
||||
}
|
||||
const note = input.note?.trim() ? input.note.trim() : null
|
||||
if (note && note.length > 2000) {
|
||||
throw new BokslutChecklistError('Noteringen är för lång.', 'NOTE_TOO_LONG')
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const { data, error } = await supabase
|
||||
.from('bokslut_checklist_items')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
item_key: input.item_key,
|
||||
state: input.state,
|
||||
note,
|
||||
done_by: input.state === 'open' ? null : userId,
|
||||
done_at: input.state === 'open' ? null : now,
|
||||
updated_by: userId,
|
||||
updated_at: now,
|
||||
},
|
||||
{ onConflict: 'company_id,fiscal_period_id,item_key' },
|
||||
)
|
||||
.select('item_key, state, note, done_by, done_at, updated_by, updated_at')
|
||||
.single()
|
||||
if (error) throw new Error(`Kunde inte spara checklistan: ${error.message}`)
|
||||
return data as ChecklistRow
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
||||
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
|
||||
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { describeFiscalYearGap, findFiscalYearGaps, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
|
||||
import type { YearEndBlocker, YearEndValidation } from '@/types'
|
||||
|
||||
const log = createLogger('bokslut-readiness')
|
||||
@@ -76,6 +77,26 @@ export interface BokslutReadinessReport {
|
||||
* Phase 2 will replace each reminder with a concrete proposal once the
|
||||
* relevant calculator ships.
|
||||
*/
|
||||
/**
|
||||
* A missing räkenskapsår anywhere in the chain is a warning on every
|
||||
* bokslut: balances do not roll across a hole (a one-file SIE migration
|
||||
* that skipped a year is the usual cause). Advisory: a failed read costs
|
||||
* only this warning.
|
||||
*/
|
||||
async function fiscalYearGapWarnings(supabase: SupabaseClient, companyId: string): Promise<string[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
if (error) throw new Error(error.message)
|
||||
return findFiscalYearGaps((data ?? []) as PeriodLike[]).map(describeFiscalYearGap)
|
||||
} catch (err) {
|
||||
log.warn('fiscal year gap check failed', { companyId, error: err instanceof Error ? err.message : String(err) })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBokslutReadinessReport(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -249,7 +270,7 @@ export async function buildBokslutReadinessReport(
|
||||
ready: validation.ready,
|
||||
blockers: validation.errors,
|
||||
blockerItems: validation.blockers,
|
||||
warnings: validation.warnings,
|
||||
warnings: [...validation.warnings, ...(await fiscalYearGapWarnings(supabase, companyId))],
|
||||
reminders,
|
||||
draftCount: validation.draftCount,
|
||||
unexplainedGapCount: validation.unexplainedGaps.length,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describeFiscalYearGap, findFiscalYearGaps } from '../fiscal-year-gaps'
|
||||
|
||||
const p = (id: string, start: string, end: string) => ({ id, name: `Räkenskapsår ${start.slice(0, 4)}`, period_start: start, period_end: end })
|
||||
|
||||
describe('findFiscalYearGaps', () => {
|
||||
it('finds a missing calendar year between two periods regardless of input order', () => {
|
||||
const gaps = findFiscalYearGaps([p('c', '2026-01-01', '2026-12-31'), p('a', '2024-01-01', '2024-12-31')])
|
||||
expect(gaps).toHaveLength(1)
|
||||
expect(gaps[0]).toMatchObject({ missing_from: '2025-01-01', missing_to: '2025-12-31' })
|
||||
expect(gaps[0].after.id).toBe('a')
|
||||
expect(gaps[0].before.id).toBe('c')
|
||||
})
|
||||
|
||||
it('reports nothing for adjacent periods, broken years included, and for a single period', () => {
|
||||
expect(findFiscalYearGaps([p('a', '2024-07-01', '2025-06-30'), p('b', '2025-07-01', '2026-06-30')])).toEqual([])
|
||||
expect(findFiscalYearGaps([p('a', '2024-01-01', '2024-12-31')])).toEqual([])
|
||||
expect(findFiscalYearGaps([])).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores overlaps and finds every hole in a longer chain', () => {
|
||||
const gaps = findFiscalYearGaps([
|
||||
p('a', '2021-01-01', '2021-12-31'),
|
||||
p('b', '2021-06-01', '2022-05-31'),
|
||||
p('c', '2024-01-01', '2024-12-31'),
|
||||
p('d', '2027-01-01', '2027-12-31'),
|
||||
])
|
||||
expect(gaps.map((g) => [g.missing_from, g.missing_to])).toEqual([
|
||||
['2022-06-01', '2023-12-31'],
|
||||
['2025-01-01', '2026-12-31'],
|
||||
])
|
||||
})
|
||||
|
||||
it('describes a gap in Swedish with both neighbours named', () => {
|
||||
const [gap] = findFiscalYearGaps([p('a', '2024-01-01', '2024-12-31'), p('c', '2026-01-01', '2026-12-31')])
|
||||
expect(describeFiscalYearGap(gap)).toMatch(/2025-01-01 till 2025-12-31 \(mellan Räkenskapsår 2024 och Räkenskapsår 2026\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Missing räkenskapsår between the ones a company has. SIE exports carry one
|
||||
* year per file, so a migration that imported 2024 and let the app create
|
||||
* 2026 leaves 2025 absent: balances stop rolling, the 2026 IB is empty, and
|
||||
* nothing said so until a bokslut failed on continuity. Pure and UTC-only,
|
||||
* shared by the readiness warnings, the import result screen and the
|
||||
* checklist.
|
||||
*/
|
||||
|
||||
export interface PeriodLike {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export interface FiscalYearGap {
|
||||
/** The period before the hole. */
|
||||
after: PeriodLike
|
||||
/** The period after the hole. */
|
||||
before: PeriodLike
|
||||
/** First and last missing day (inclusive). */
|
||||
missing_from: string
|
||||
missing_to: string
|
||||
}
|
||||
|
||||
function shiftIsoDate(isoDate: string, days: number): string {
|
||||
const d = new Date(isoDate + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Every hole between consecutive periods, oldest first. Overlaps are not gaps and are ignored. */
|
||||
export function findFiscalYearGaps(periods: readonly PeriodLike[]): FiscalYearGap[] {
|
||||
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
const gaps: FiscalYearGap[] = []
|
||||
for (let i = 0; i + 1 < sorted.length; i += 1) {
|
||||
const after = sorted[i]
|
||||
const before = sorted[i + 1]
|
||||
const expectedStart = shiftIsoDate(after.period_end, 1)
|
||||
if (before.period_start > expectedStart) {
|
||||
gaps.push({
|
||||
after,
|
||||
before,
|
||||
missing_from: expectedStart,
|
||||
missing_to: shiftIsoDate(before.period_start, -1),
|
||||
})
|
||||
}
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
/** Swedish, user-facing: "Räkenskapsår saknas: 2025-01-01 till 2025-12-31 (mellan Räkenskapsår 2024 och Räkenskapsår 2026)." */
|
||||
export function describeFiscalYearGap(gap: FiscalYearGap): string {
|
||||
return `Räkenskapsår saknas: ${gap.missing_from} till ${gap.missing_to} (mellan ${gap.after.name} och ${gap.before.name}). Importera eller skapa det innan bokslutet, annars rullar inga balanser fram.`
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
getAttachmentRow,
|
||||
insertAttachmentRow,
|
||||
listAttachmentRows,
|
||||
listAttachmentRowsInRange,
|
||||
stampAttachmentRemoved,
|
||||
toPublicAttachment,
|
||||
} from '../attachments-store'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const TABLE = 'account_reconciliation_attachments'
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'a1',
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
file_name: 'engagemangsbesked.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: '12345',
|
||||
storage_bucket: 'documents',
|
||||
storage_path: 'company-1/reconciliation/manual:2350/2026-12-31/a1.pdf',
|
||||
sha256: 'ab'.repeat(32),
|
||||
note: null,
|
||||
uploaded_by: 'u1',
|
||||
uploaded_at: '2027-01-10T08:00:00Z',
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removed_reason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('attachments-store', () => {
|
||||
it('lists active files for one account and balansdag, coercing size to a number', async () => {
|
||||
const { supabase, enqueue, findCalls, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: [row()] })
|
||||
const rows = await listAttachmentRows(supabase as never, COMPANY, 'manual:2350', '2026-12-31')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].size_bytes).toBe(12345)
|
||||
const eqs = findCalls(TABLE, 'eq').map((a) => a[0])
|
||||
expect(eqs).toEqual(['company_id', 'account_key', 'through_date'])
|
||||
expect(findCall(TABLE, 'is')).toEqual(['removed_at', null])
|
||||
})
|
||||
|
||||
it('includes removed rows only on request', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: [row({ removed_at: '2027-01-11T08:00:00Z', removed_by: 'u1' })] })
|
||||
const rows = await listAttachmentRows(supabase as never, COMPANY, 'manual:2350', '2026-12-31', { includeRemoved: true })
|
||||
expect(rows[0].removed_at).toBe('2027-01-11T08:00:00Z')
|
||||
expect(findCall(TABLE, 'is')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists a date range for the pärm', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: [row(), row({ id: 'a2', account_key: 'skattekonto' })] })
|
||||
const rows = await listAttachmentRowsInRange(supabase as never, COMPANY, '2026-01-01', '2026-12-31')
|
||||
expect(rows.map((r) => r.id)).toEqual(['a1', 'a2'])
|
||||
expect(findCall(TABLE, 'gte')).toEqual(['through_date', '2026-01-01'])
|
||||
expect(findCall(TABLE, 'lte')).toEqual(['through_date', '2026-12-31'])
|
||||
})
|
||||
|
||||
it('strips bucket and path from the public shape', () => {
|
||||
const pub = toPublicAttachment({ ...row(), size_bytes: 12345 } as never)
|
||||
expect(pub).not.toHaveProperty('storage_path')
|
||||
expect(pub).not.toHaveProperty('storage_bucket')
|
||||
expect(pub.file_name).toBe('engagemangsbesked.pdf')
|
||||
})
|
||||
|
||||
it('inserts with the company id and returns the row', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: row() })
|
||||
const r = await insertAttachmentRow(supabase as never, COMPANY, {
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
file_name: 'engagemangsbesked.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 12345,
|
||||
storage_bucket: 'documents',
|
||||
storage_path: 'company-1/reconciliation/manual:2350/2026-12-31/a1.pdf',
|
||||
sha256: 'ab'.repeat(32),
|
||||
note: null,
|
||||
uploaded_by: 'u1',
|
||||
})
|
||||
expect(r.id).toBe('a1')
|
||||
expect(findCall(TABLE, 'insert')?.[0]).toMatchObject({ company_id: COMPANY, account_key: 'manual:2350', uploaded_by: 'u1' })
|
||||
})
|
||||
|
||||
it('stamps removal only on an active row and returns null otherwise', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
expect(await stampAttachmentRemoved(supabase as never, COMPANY, 'a1', { removed_by: 'u1', reason: null })).toBeNull()
|
||||
expect(findCall(TABLE, 'update')?.[0]).toMatchObject({ removed_by: 'u1', removed_reason: null })
|
||||
expect(findCall(TABLE, 'is')).toEqual(['removed_at', null])
|
||||
})
|
||||
|
||||
it('reads one row by id within the account scope', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: row() })
|
||||
const r = await getAttachmentRow(supabase as never, COMPANY, 'manual:2350', 'a1')
|
||||
expect(r?.storage_path).toContain('reconciliation')
|
||||
enqueue({ data: null })
|
||||
expect(await getAttachmentRow(supabase as never, COMPANY, 'manual:2350', 'nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const listRowsMock = vi.fn()
|
||||
const insertRowMock = vi.fn()
|
||||
const getRowMock = vi.fn()
|
||||
const stampMock = vi.fn()
|
||||
vi.mock('../attachments-store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../attachments-store')>('../attachments-store')
|
||||
return {
|
||||
...actual,
|
||||
listAttachmentRows: (...args: unknown[]) => listRowsMock(...args),
|
||||
insertAttachmentRow: (...args: unknown[]) => insertRowMock(...args),
|
||||
getAttachmentRow: (...args: unknown[]) => getRowMock(...args),
|
||||
stampAttachmentRemoved: (...args: unknown[]) => stampMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
attachUnderlag,
|
||||
attachmentStoragePath,
|
||||
listAttachments,
|
||||
ReconciliationAttachmentError,
|
||||
removeUnderlag,
|
||||
} from '../attachments'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
// %PDF-1.4 header followed by padding: passes the magic-byte check.
|
||||
const PDF_BYTES = new TextEncoder().encode('%PDF-1.4\n%âãÏÓ\n1 0 obj\n<<>>\nendobj\n').buffer as ArrayBuffer
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
file_name: 'engagemangsbesked.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: PDF_BYTES.byteLength,
|
||||
storage_bucket: 'documents',
|
||||
storage_path: `documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/x_engagemangsbesked.pdf`,
|
||||
sha256: 'ab'.repeat(32),
|
||||
note: null,
|
||||
uploaded_by: USER,
|
||||
uploaded_at: '2027-01-10T08:00:00Z',
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removed_reason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
listRowsMock.mockReset()
|
||||
insertRowMock.mockReset()
|
||||
getRowMock.mockReset()
|
||||
stampMock.mockReset()
|
||||
})
|
||||
|
||||
describe('attachmentStoragePath', () => {
|
||||
it('lives under documents/<company>/ so the bucket RLS applies, with the key colon replaced', () => {
|
||||
const p = attachmentStoragePath(COMPANY, 'manual:2350', '2026-12-31', 'Kontoutdrag dec/2026.pdf', 'abc')
|
||||
expect(p).toBe(`documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/abc_Kontoutdrag dec_2026.pdf`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAttachments', () => {
|
||||
it('returns the public shape and rejects a bad scope', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
listRowsMock.mockResolvedValue([row()])
|
||||
const list = await listAttachments(supabase as never, COMPANY, 'manual:2350', '2026-12-31')
|
||||
expect(list[0]).not.toHaveProperty('storage_path')
|
||||
expect(list[0].file_name).toBe('engagemangsbesked.pdf')
|
||||
await expect(listAttachments(supabase as never, COMPANY, '2350', '2026-12-31')).rejects.toMatchObject({ code: 'INVALID_ACCOUNT_KEY' })
|
||||
await expect(listAttachments(supabase as never, COMPANY, 'manual:2350', '31/12/2026')).rejects.toMatchObject({ code: 'INVALID_DATE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachUnderlag', () => {
|
||||
it('validates, hashes, uploads to the documents bucket, then records the row', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
insertRowMock.mockImplementation(async (_s: unknown, _c: unknown, input: Record<string, unknown>) => row(input))
|
||||
const result = await attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
note: ' Engagemangsbesked ',
|
||||
file: { name: 'engagemangsbesked.pdf', type: 'application/pdf', size: PDF_BYTES.byteLength, buffer: PDF_BYTES },
|
||||
})
|
||||
const upload = supabase.storage.from('documents').upload as ReturnType<typeof vi.fn>
|
||||
expect(supabase.storage.from).toHaveBeenCalledWith('documents')
|
||||
const [path, , opts] = upload.mock.calls[0] as [string, unknown, { contentType: string; upsert: boolean }]
|
||||
expect(path).toMatch(new RegExp(`^documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/`))
|
||||
expect(opts).toEqual({ contentType: 'application/pdf', upsert: false })
|
||||
expect(insertRowMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
expect.objectContaining({
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
note: 'Engagemangsbesked',
|
||||
uploaded_by: USER,
|
||||
storage_bucket: 'documents',
|
||||
sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
}),
|
||||
)
|
||||
expect(result.file_name).toBe('engagemangsbesked.pdf')
|
||||
})
|
||||
|
||||
it('refuses unsupported types and content that does not match the declared type without uploading', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
file: { name: 'x.csv', type: 'text/csv', size: 10, buffer: new ArrayBuffer(10) },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ReconciliationAttachmentError)
|
||||
await expect(
|
||||
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
file: { name: 'x.pdf', type: 'application/pdf', size: 10, buffer: new ArrayBuffer(10) },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'INVALID_FILE' })
|
||||
expect(supabase.storage.from('documents').upload).not.toHaveBeenCalled()
|
||||
expect(insertRowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not record a row when the upload fails', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
;(supabase.storage.from('documents').upload as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: null,
|
||||
error: { message: 'bucket down' },
|
||||
})
|
||||
await expect(
|
||||
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
file: { name: 'x.pdf', type: 'application/pdf', size: PDF_BYTES.byteLength, buffer: PDF_BYTES },
|
||||
}),
|
||||
).rejects.toThrow(/bucket down/)
|
||||
expect(insertRowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeUnderlag', () => {
|
||||
it('stamps an active row, refuses a removed one, and returns null for an unknown id', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
getRowMock.mockResolvedValue(row())
|
||||
stampMock.mockResolvedValue(row({ removed_at: '2027-01-11T08:00:00Z', removed_by: USER, removed_reason: 'fel fil' }))
|
||||
const removed = await removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id, { reason: ' fel fil ' })
|
||||
expect(removed?.removed_reason).toBe('fel fil')
|
||||
expect(stampMock).toHaveBeenCalledWith(supabase, COMPANY, row().id, { removed_by: USER, reason: 'fel fil' })
|
||||
|
||||
getRowMock.mockResolvedValue(row({ removed_at: '2027-01-11T08:00:00Z', removed_by: USER }))
|
||||
await expect(removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id)).rejects.toMatchObject({ code: 'ALREADY_REMOVED' })
|
||||
|
||||
getRowMock.mockResolvedValue(null)
|
||||
expect(await removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ReconciliationAttachment } from './schemas'
|
||||
|
||||
/**
|
||||
* The account_reconciliation_attachments table, read and written in one
|
||||
* place. Pure storage rows: the file bytes live in Supabase Storage and are
|
||||
* handled by attachments.ts, so the pärm and the archive can list underlag
|
||||
* without touching the bucket.
|
||||
*/
|
||||
|
||||
export interface AttachmentRow extends ReconciliationAttachment {
|
||||
storage_bucket: string
|
||||
storage_path: string
|
||||
}
|
||||
|
||||
function mapRow(row: Record<string, unknown>): AttachmentRow {
|
||||
return {
|
||||
id: row.id as string,
|
||||
account_key: row.account_key as string,
|
||||
through_date: row.through_date as string,
|
||||
file_name: row.file_name as string,
|
||||
mime_type: row.mime_type as string,
|
||||
size_bytes: Number(row.size_bytes ?? 0),
|
||||
storage_bucket: row.storage_bucket as string,
|
||||
storage_path: row.storage_path as string,
|
||||
sha256: row.sha256 as string,
|
||||
note: (row.note as string | null) ?? null,
|
||||
uploaded_by: row.uploaded_by as string,
|
||||
uploaded_at: row.uploaded_at as string,
|
||||
removed_at: (row.removed_at as string | null) ?? null,
|
||||
removed_by: (row.removed_by as string | null) ?? null,
|
||||
removed_reason: (row.removed_reason as string | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The public shape: no bucket or path (those are served through the file route). */
|
||||
export function toPublicAttachment(row: AttachmentRow): ReconciliationAttachment {
|
||||
const { storage_bucket: _bucket, storage_path: _path, ...rest } = row
|
||||
void _bucket
|
||||
void _path
|
||||
return rest
|
||||
}
|
||||
|
||||
export interface ListAttachmentsOptions {
|
||||
includeRemoved?: boolean
|
||||
}
|
||||
|
||||
/** Files for one account and balansdag, oldest first (the order they were attached). */
|
||||
export async function listAttachmentRows(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
throughDate: string,
|
||||
options: ListAttachmentsOptions = {},
|
||||
): Promise<AttachmentRow[]> {
|
||||
let query = supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.eq('through_date', throughDate)
|
||||
.order('uploaded_at', { ascending: true })
|
||||
if (!options.includeRemoved) query = query.is('removed_at', null)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
|
||||
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
|
||||
}
|
||||
|
||||
/** Every active file with a balansdag inside [from, to], for the pärm and the archive. */
|
||||
export async function listAttachmentRowsInRange(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
options: ListAttachmentsOptions = {},
|
||||
): Promise<AttachmentRow[]> {
|
||||
let query = supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.eq('company_id', companyId)
|
||||
.gte('through_date', from)
|
||||
.lte('through_date', to)
|
||||
.order('account_key', { ascending: true })
|
||||
.order('through_date', { ascending: true })
|
||||
.order('uploaded_at', { ascending: true })
|
||||
.limit(2000)
|
||||
if (!options.includeRemoved) query = query.is('removed_at', null)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
|
||||
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
|
||||
}
|
||||
|
||||
export async function getAttachmentRow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.eq('id', attachmentId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
|
||||
return data ? mapRow(data as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
export interface InsertAttachmentInput {
|
||||
account_key: string
|
||||
through_date: string
|
||||
file_name: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
storage_bucket: string
|
||||
storage_path: string
|
||||
sha256: string
|
||||
note: string | null
|
||||
uploaded_by: string
|
||||
}
|
||||
|
||||
export async function insertAttachmentRow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: InsertAttachmentInput,
|
||||
): Promise<AttachmentRow> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
account_key: input.account_key,
|
||||
through_date: input.through_date,
|
||||
file_name: input.file_name,
|
||||
mime_type: input.mime_type,
|
||||
size_bytes: input.size_bytes,
|
||||
storage_bucket: input.storage_bucket,
|
||||
storage_path: input.storage_path,
|
||||
sha256: input.sha256,
|
||||
note: input.note,
|
||||
uploaded_by: input.uploaded_by,
|
||||
})
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.single()
|
||||
if (error) throw new Error(`Kunde inte spara underlag: ${error.message}`)
|
||||
return mapRow(data as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** Removal is a stamp; the row and the file stay (BFL 7 kap.). Null when already removed or missing. */
|
||||
export async function stampAttachmentRemoved(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
attachmentId: string,
|
||||
input: { removed_by: string; reason: string | null },
|
||||
): Promise<AttachmentRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.update({ removed_at: new Date().toISOString(), removed_by: input.removed_by, removed_reason: input.reason })
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', attachmentId)
|
||||
.is('removed_at', null)
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte ta bort underlag: ${error.message}`)
|
||||
return data ? mapRow(data as Record<string, unknown>) : null
|
||||
}
|
||||
Binary file not shown.
@@ -80,6 +80,24 @@ export const ReconciliationSignoffSchema = z.object({
|
||||
})
|
||||
export type ReconciliationSignoff = z.infer<typeof ReconciliationSignoffSchema>
|
||||
|
||||
/** One underlag file attached to an account's balansdag (account_reconciliation_attachments). */
|
||||
export const ReconciliationAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
account_key: AccountKeySchema,
|
||||
through_date: z.string(),
|
||||
file_name: z.string(),
|
||||
mime_type: z.string(),
|
||||
size_bytes: z.number().int(),
|
||||
sha256: z.string(),
|
||||
note: z.string().nullable(),
|
||||
uploaded_by: z.string(),
|
||||
uploaded_at: z.string(),
|
||||
removed_at: z.string().nullable(),
|
||||
removed_by: z.string().nullable(),
|
||||
removed_reason: z.string().nullable(),
|
||||
})
|
||||
export type ReconciliationAttachment = z.infer<typeof ReconciliationAttachmentSchema>
|
||||
|
||||
export const ReconciliationAccountSchema = z.object({
|
||||
account_key: AccountKeySchema,
|
||||
kind: ReconciliationKindSchema,
|
||||
|
||||
@@ -51,6 +51,11 @@ vi.mock('../journal-register', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// The bilagor step reads account_reconciliation_attachments through the
|
||||
// store; an empty list keeps the queued-mock order of these tests intact.
|
||||
vi.mock('@/lib/reconciliation/attachments-store', () => ({
|
||||
listAttachmentRowsInRange: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
vi.mock('../vat-declaration', () => ({
|
||||
calculateVatDeclaration: vi.fn().mockResolvedValue({
|
||||
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { generateJournalRegister } from './journal-register'
|
||||
import { calculateVatDeclaration } from './vat-declaration'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
|
||||
import { listAttachmentRowsInRange } from '@/lib/reconciliation/attachments-store'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import {
|
||||
@@ -171,6 +172,7 @@ export async function generateFullArchive(
|
||||
|
||||
if (options.include_documents !== false) {
|
||||
await writeDocuments(zip, supabase, companyId, periods, options.scope)
|
||||
await writeReconciliationAttachments(zip, supabase, companyId, periods)
|
||||
}
|
||||
|
||||
if (options.scope === 'all') {
|
||||
@@ -554,6 +556,94 @@ async function writeDocuments(
|
||||
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
interface ReconciliationAttachmentManifestEntry {
|
||||
attachment_id: string
|
||||
account_key: string
|
||||
through_date: string
|
||||
file_name: string
|
||||
storage_path: string
|
||||
sha256: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
note: string | null
|
||||
uploaded_at: string
|
||||
removed_at: string | null
|
||||
removed_reason: string | null
|
||||
zip_path: string | null
|
||||
status: 'downloaded' | 'removed' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The underlag behind the reconciliation sign-offs (bokslutsbilagor): every
|
||||
* file attached to a balansdag inside the archived periods, laid out as
|
||||
* `bilagor/<period>/<account_key>/<through_date>_<file>`, plus a manifest
|
||||
* with the content hashes. Removed files are listed (with their stamp) but
|
||||
* not copied: the manifest is the record that they were attached and then
|
||||
* withdrawn. A failed read lands in the manifest rather than aborting the
|
||||
* archive, like writeDocuments.
|
||||
*/
|
||||
async function writeReconciliationAttachments(
|
||||
zip: JSZip,
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: FiscalPeriodRow[]
|
||||
): Promise<void> {
|
||||
const manifest: ReconciliationAttachmentManifestEntry[] = []
|
||||
const usedPaths = new Set<string>()
|
||||
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
const from = sorted[0].period_start
|
||||
const to = sorted[sorted.length - 1].period_end
|
||||
|
||||
try {
|
||||
const rows = await listAttachmentRowsInRange(supabase, companyId, from, to, { includeRemoved: true })
|
||||
for (const row of rows) {
|
||||
const period = sorted.find((p) => row.through_date >= p.period_start && row.through_date <= p.period_end)
|
||||
const base = {
|
||||
attachment_id: row.id,
|
||||
account_key: row.account_key,
|
||||
through_date: row.through_date,
|
||||
file_name: row.file_name,
|
||||
storage_path: row.storage_path,
|
||||
sha256: row.sha256,
|
||||
mime_type: row.mime_type,
|
||||
size_bytes: row.size_bytes,
|
||||
note: row.note,
|
||||
uploaded_at: row.uploaded_at,
|
||||
removed_at: row.removed_at,
|
||||
removed_reason: row.removed_reason,
|
||||
}
|
||||
if (row.removed_at) {
|
||||
manifest.push({ ...base, zip_path: null, status: 'removed' })
|
||||
continue
|
||||
}
|
||||
if (!period) continue
|
||||
let zipPath = `bilagor/${periodLabel(period)}/${row.account_key.replace(':', '_')}/${row.through_date}_${row.file_name}`
|
||||
if (usedPaths.has(zipPath)) {
|
||||
const dot = zipPath.lastIndexOf('.')
|
||||
const suffix = `_${row.id.slice(0, 8)}`
|
||||
zipPath = dot > zipPath.lastIndexOf('/') ? `${zipPath.slice(0, dot)}${suffix}${zipPath.slice(dot)}` : `${zipPath}${suffix}`
|
||||
}
|
||||
usedPaths.add(zipPath)
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from(row.storage_bucket).download(row.storage_path)
|
||||
if (error || !data) {
|
||||
manifest.push({ ...base, zip_path: null, status: 'error', error: error?.message || 'Download returned no data' })
|
||||
continue
|
||||
}
|
||||
zip.file(zipPath, await data.arrayBuffer())
|
||||
manifest.push({ ...base, zip_path: zipPath, status: 'downloaded' })
|
||||
} catch (err) {
|
||||
manifest.push({ ...base, zip_path: null, status: 'error', error: err instanceof Error ? err.message : 'Unknown error' })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Attachment listing failed: the archive still carries everything else.
|
||||
}
|
||||
|
||||
zip.folder('bilagor')!.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgREST returns a many-to-one embedded resource as either an object or an
|
||||
* array depending on schema introspection (FK is unique vs not). Normalize.
|
||||
@@ -988,6 +1078,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
// through which date with the numbers as they stood, plus reopen stamps.
|
||||
// Part of the avstämningsdokumentation an auditor asks for; kept.
|
||||
{ name: 'account_reconciliations', file: 'account_reconciliations.json', orderBy: 'signed_at' },
|
||||
// The bokslut checklist per räkenskapsår (which closing steps were done,
|
||||
// by whom, when): the konsult's documented bokslutsarbete (Reko 760); kept.
|
||||
{ name: 'bokslut_checklist_items', file: 'bokslut_checklist_items.json', orderBy: 'updated_at', pageKey: 'item_key' },
|
||||
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
|
||||
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
|
||||
// No `denormalize`: rot_rut_payout_requests has no currency column either.
|
||||
@@ -1017,6 +1110,7 @@ export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record<string, string> = {
|
||||
voucher_sequences: 'revision/systemdokumentation.json (verifikationsserier)',
|
||||
audit_log: 'revision/behandlingshistorik.json',
|
||||
document_attachments: 'dokument/ + dokument/manifest.json',
|
||||
account_reconciliation_attachments: 'bilagor/ + bilagor/manifest.json',
|
||||
sie_imports: 'sie/imports.json + sie/original/',
|
||||
sie_account_mappings: 'sie/account_mappings.json',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user