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