fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only (#2393)

* fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only

markPeriodClosedExternally decided "migrated" by asking whether the period
had any non-imported verifikat at all. That was a proxy for the thing the
guard protects (a bokslutsverifikat transferring 3xxx-8xxx, BFL 5-6 kap),
and it shut out the migrated first year whose only native voucher re-keys
the opening balance (1930 D / 2081 K aktiekapital) after a failed SIE
import. With the next year's IB already imported, the normal year-end
refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all.

The guard now asks the ledger the real question: are there lines on result
accounts (BAS class 3-8) in the period? Id-only entry fetch plus per-chunk
head counts with early exit, no line fetch, no journal_entries!inner embed.
The refusal message names what was found and why the normal year-end is
the remedy.

The loaded årsredovisning view also gets the FyPicker in its header. The
no-period branch auto-jumps to the remembered scope (or the newest year)
before its own picker is ever seen, so a user whose scope pointed at the
historical year had no way to reach the current year's årsredovisning
except by changing the scope on some other page and coming back.

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

* fix(period): PR #2393 round 1: require next-year IB for balance-sheet-only klarmarkera, bound result range to class 3-8

Triage of the swedish-compliance review:

- Balance-sheet-only native years bypassing the bokslut: partly real. The
  result transfer is not the only thing the normal year-end does; it also
  posts the next year's IB. A natively bookkept year with only balance-
  sheet vouchers (dormant AB, aktiekapital deposit only) could have been
  klarmarkerad and its balances would never have reached the next year.
  The leg now additionally requires the next period to carry IB already,
  which is exactly the shape where the normal year-end refuses
  (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path. Without that IB
  the normal year-end works and stays the remedy, and the message says so.
  The suggested IB-correction tag on entries was not adopted: the ledger
  already answers the question, a tag would be one more concept to know.
- Unbounded account range: real, cosmetic. Bounded with lt '9' so class 9
  interna poster no longer count as result accounts. Storage format is the
  4-digit BAS string every classifier in the codebase already relies on
  (isBalanceSheetAccount reads charAt(0)); the text compare is as robust as
  those.

Tests: next year with IB passes, next year without IB refused, no next
year refused, lt('account_number', '9') asserted.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-07 20:10:41 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 57a5af1310
commit 5d735097a1
4 changed files with 231 additions and 24 deletions
@@ -11,7 +11,7 @@ let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is', 'range']) {
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lt', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -691,7 +691,7 @@ describe('markPeriodClosedExternally', () => {
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ count: 0, data: null, error: null }, // total-verifikat count
{ data: [], error: null }, // period entries (id-only page): none
{ count: 0, data: null, error: null }, // guard leg 1: untriaged count
{ data: [], error: null }, // guard leg 2: business-unbooked candidates
{ data: updated, error: null }, // update
@@ -703,12 +703,90 @@ describe('markPeriodClosedExternally', () => {
expect(result.closed_externally).toBe(true)
})
it('refuses a period bookkept natively in Accounted (no imported verifikat)', async () => {
it('allows a migrated first year whose only native voucher is balance-sheet only and the next year has IB', async () => {
// The EHAL shape (2026-09-07): SIE import into the historical year failed,
// so the owner re-keyed the opening voucher by hand (1930 D / 2081 K
// aktiekapital). Nothing on 3xxx-8xxx, so there is no result for a
// bokslutsverifikat to transfer; the next year's IB is already imported,
// which blocks the normal year-end. Klarmarkera must stay open here.
const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' })
const next = makeFiscalPeriod({
id: 'fp-2',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: 'fp-1',
opening_balance_entry_id: 'ib-1',
})
const updated = { ...period, is_closed: true, closed_externally: true }
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ data: [{ id: 'je-1' }], error: null }, // period entries (id-only page)
{ count: 0, data: null, error: null }, // result-account lines in chunk 1: none
{ data: period, error: null }, // findNextPeriod: fetch current
{ data: next, error: null }, // findNextPeriod: chained lookup, has IB
{ count: 0, data: null, error: null }, // guard leg 1: untriaged count
{ data: [], error: null }, // guard leg 2: business-unbooked candidates
{ data: updated, error: null }, // update
{ data: null, error: null }, // audit_log insert
]
const supabase = makeClient()
const result = await markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.closed_externally).toBe(true)
// The line check is a text comparison on the account number string:
// classes 3-8 sort at or above '3' and below '9'.
const lineBuilders = supabase.from.mock.results
.map(
(r) =>
r.value as {
in: ReturnType<typeof vi.fn>
gte: ReturnType<typeof vi.fn>
lt: ReturnType<typeof vi.fn>
},
)
.filter((b) => b.in.mock.calls.some((c) => c[0] === 'journal_entry_id'))
expect(lineBuilders).toHaveLength(1)
expect(lineBuilders[0].in).toHaveBeenCalledWith('journal_entry_id', ['je-1'])
expect(lineBuilders[0].gte).toHaveBeenCalledWith('account_number', '3')
expect(lineBuilders[0].lt).toHaveBeenCalledWith('account_number', '9')
})
it('refuses a native balance-sheet-only year when the next year has no IB (normal year-end carries the balances)', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' })
const next = makeFiscalPeriod({
id: 'fp-2',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: 'fp-1',
opening_balance_entry_id: null,
})
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ data: [{ id: 'je-1' }], error: null }, // period entries: one native voucher
{ count: 0, data: null, error: null }, // result-account lines: none
{ data: period, error: null }, // findNextPeriod: fetch current
{ data: next, error: null }, // findNextPeriod: chained lookup, no IB
]
const supabase = makeClient()
await expect(
markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1')
).rejects.toThrow('saknar ingående balanser')
})
it('refuses a native balance-sheet-only year when no next period exists at all', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' })
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ count: 7, data: null, error: null }, // total-verifikat count: native entries
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ data: [{ id: 'je-1' }], error: null }, // period entries: one native voucher
{ count: 0, data: null, error: null }, // result-account lines: none
{ data: period, error: null }, // findNextPeriod: fetch current
{ data: null, error: null }, // findNextPeriod: chained lookup misses
{ data: null, error: null }, // findNextPeriod: date lookup misses
]
const supabase = makeClient()
@@ -717,6 +795,36 @@ describe('markPeriodClosedExternally', () => {
).rejects.toThrow('vanliga årsbokslutet')
})
it('refuses a period bookkept natively in Accounted (result accounts, no imported verifikat)', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' })
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ data: [{ id: 'je-1' }, { id: 'je-2' }], error: null }, // period entries: native
{ count: 4, data: null, error: null }, // result-account lines in chunk 1
]
const supabase = makeClient()
await expect(
markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1')
).rejects.toThrow('vanliga årsbokslutet')
})
it('fails closed when the result-account line check errors', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' })
results = [
{ data: period, error: null }, // fetch
{ count: 0, data: null, error: null }, // imported-verifikat count
{ data: [{ id: 'je-1' }], error: null }, // period entries
{ count: null, data: null, error: { message: 'boom' } }, // line head-count fails
]
const supabase = makeClient()
await expect(
markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1')
).rejects.toThrow('Kunde inte kontrollera periodens verifikat')
})
it('rejects an already-closed period', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: true })
results = [{ data: period, error: null }]