feat(periods): undo klarmarkera so an externally closed year can be reopened (#1978)

markPeriodClosedExternally ("klarmarkera") closes and locks an imported
year without a closing entry, and nothing could reverse it: unlockPeriod
refuses closed periods and the SIE replace flow refuses closed or locked
years. An owner who klarmarkerade five imported years and then found the
prior-year SIE file was wrong had no way back (Forsslund Systems,
2026-08-27).

reopenExternallyClosedPeriod reverses the mark while the closed state still
comes from klarmarkera (closed_externally set, no closing entry), clears the
lock, writes the audit_log row, and emits period.unlocked. New route
POST /api/bookkeeping/fiscal-periods/[id]/reopen-external with envelope codes
PERIOD_REOPEN_NOT_CLOSED / PERIOD_REOPEN_NOT_EXTERNAL; "Öppna igen" action
and "Avslutat i tidigare program" chip in Settings > Bookkeeping > Fiscal
years; unlock and SIE replace refusals now point at that path.

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:
Jakob Wennberg
2026-08-27 14:34:02 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent c981384a0e
commit dfed55cb6c
10 changed files with 403 additions and 7 deletions
@@ -34,6 +34,7 @@ import {
unlockPeriod,
closePeriod,
markPeriodClosedExternally,
reopenExternallyClosedPeriod,
createNextPeriod,
findNextPeriod,
resolvePeriodStatusForDate,
@@ -766,6 +767,84 @@ describe('markPeriodClosedExternally', () => {
})
})
describe('reopenExternallyClosedPeriod', () => {
it('reopens a klarmarkerad period, clears the lock, and writes the audit row', async () => {
const period = makeFiscalPeriod({
id: 'fp-1',
is_closed: true,
closed_at: '2026-08-27T09:20:13Z',
closed_externally: true,
locked_at: '2026-08-27T09:20:13Z',
closing_entry_id: null,
})
const reopened = {
...period,
is_closed: false,
closed_at: null,
closed_externally: false,
locked_at: null,
}
results = [
{ data: period, error: null }, // fetch
{ data: reopened, error: null }, // update
{ data: null, error: null }, // audit_log insert
]
const handler = vi.fn()
eventBus.on('period.unlocked', handler)
const supabase = makeClient()
const result = await reopenExternallyClosedPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.is_closed).toBe(false)
expect(result.closed_externally).toBe(false)
expect(result.locked_at).toBeNull()
expect(handler).toHaveBeenCalledOnce()
// Three table touches: fetch, update, audit_log.
expect(supabase.from).toHaveBeenCalledWith('audit_log')
})
it('rejects an open period', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closed_externally: false })
results = [{ data: period, error: null }]
const supabase = makeClient()
await expect(
reopenExternallyClosedPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
).rejects.toThrow('not closed')
})
it('rejects a period closed by a year-end run (closing entry, not klarmarkera)', async () => {
const period = makeFiscalPeriod({
id: 'fp-1',
is_closed: true,
closed_externally: false,
closing_entry_id: 'ce-1',
})
results = [{ data: period, error: null }]
const supabase = makeClient()
await expect(
reopenExternallyClosedPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
).rejects.toThrow('year-end run')
})
it('rejects a klarmarkerad period that later got its own closing entry', async () => {
const period = makeFiscalPeriod({
id: 'fp-1',
is_closed: true,
closed_externally: true,
closing_entry_id: 'ce-1',
})
results = [{ data: period, error: null }]
const supabase = makeClient()
await expect(
reopenExternallyClosedPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
).rejects.toThrow('year-end run')
})
})
describe('unlockPeriod', () => {
it('clears locked_at and emits period.unlocked', async () => {
const period = makeFiscalPeriod({