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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
c981384a0e
commit
dfed55cb6c
@@ -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({
|
||||
|
||||
@@ -567,6 +567,106 @@ export async function markPeriodClosedExternally(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo "klarmarkera": reopen a period that markPeriodClosedExternally closed.
|
||||
*
|
||||
* The close was a person's control decision, not a year-end run, so undoing
|
||||
* it is the same kind of decision. It is allowed only while the closed state
|
||||
* still comes from klarmarkera: closed_externally is set and no closing entry
|
||||
* exists in Accounted. A period closed by closePeriod keeps its
|
||||
* bokslutsverifikat and is not reopened here. Clears locked_at as well: the
|
||||
* reason to reopen is to change the period's contents (replace a wrong
|
||||
* prior-year SIE import, add missing bokslutsdispositioner), and the user can
|
||||
* klarmarkera or lock again afterwards. Written to the immutable audit_log
|
||||
* (BFNAR 2013:2 kap. 8).
|
||||
*
|
||||
* Observed need 2026-08-27: an owner klarmarkerade five imported years, then
|
||||
* found the prior-year SIE file was wrong; replace was refused on the closed
|
||||
* year and unlock refused the closed state, with no way back.
|
||||
*/
|
||||
export async function reopenExternallyClosedPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
if (!period.is_closed) {
|
||||
throw new Error('Period is not closed')
|
||||
}
|
||||
|
||||
if (!period.closed_externally || period.closing_entry_id) {
|
||||
throw new Error(
|
||||
'Period was closed with a year-end run in Accounted and cannot be reopened here'
|
||||
)
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({
|
||||
is_closed: false,
|
||||
closed_at: null,
|
||||
closed_externally: false,
|
||||
locked_at: null,
|
||||
})
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
// TOCTOU guard, mirror of markPeriodClosedExternally: only the klarmarkera
|
||||
// state is reversible, so a concurrent normal close (closing entry set)
|
||||
// makes this a 0-row update, which .single() surfaces as an error.
|
||||
.eq('is_closed', true)
|
||||
.eq('closed_externally', true)
|
||||
.is('closing_entry_id', null)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
throw new Error(`Failed to reopen period: ${updateError?.message}`)
|
||||
}
|
||||
|
||||
const result = updated as FiscalPeriod
|
||||
|
||||
await supabase.from('audit_log').insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
action: 'UPDATE',
|
||||
table_name: 'fiscal_periods',
|
||||
record_id: fiscalPeriodId,
|
||||
description: `Period reopened, previous-system close undone: ${result.name} (${result.period_start} to ${result.period_end})`,
|
||||
old_state: {
|
||||
is_closed: true,
|
||||
closed_at: period.closed_at,
|
||||
closed_externally: true,
|
||||
locked_at: period.locked_at,
|
||||
},
|
||||
new_state: {
|
||||
is_closed: false,
|
||||
closed_at: null,
|
||||
closed_externally: false,
|
||||
locked_at: null,
|
||||
},
|
||||
})
|
||||
|
||||
// The lock is gone too, so downstream listeners see the same transition as
|
||||
// a plain unlock.
|
||||
await eventBus.emit({
|
||||
type: 'period.unlocked',
|
||||
payload: { period: result, companyId, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the next fiscal period following the current one.
|
||||
* Computes dates based on the current period's length (handles brutet räkenskapsår).
|
||||
|
||||
@@ -1523,8 +1523,18 @@ const PERIOD: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
PERIOD_UNLOCK_CLOSED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Ett stängt räkenskapsår kan inte låsas upp.',
|
||||
message_en: 'A closed fiscal year cannot be unlocked.',
|
||||
message_sv: 'Ett stängt räkenskapsår kan inte låsas upp. Klarmarkerades året som avslutat i ett tidigare program kan du i stället öppna det igen under Räkenskapsår.',
|
||||
message_en: 'A closed fiscal year cannot be unlocked. If the year was marked as closed in a previous system, reopen it from Fiscal years instead.',
|
||||
},
|
||||
PERIOD_REOPEN_NOT_CLOSED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Räkenskapsåret är inte stängt.',
|
||||
message_en: 'Fiscal year is not closed.',
|
||||
},
|
||||
PERIOD_REOPEN_NOT_EXTERNAL: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Räkenskapsåret stängdes med ett bokslut i Accounted och kan inte öppnas igen här.',
|
||||
message_en: 'The fiscal year was closed with a year-end run in Accounted and cannot be reopened here.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
|
||||
@@ -319,7 +319,7 @@ export async function replaceSIEImport(
|
||||
.single()
|
||||
|
||||
if (period?.is_closed || period?.locked_at) {
|
||||
return { success: false, deletedEntries: 0, error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.', code: 'period_locked' }
|
||||
return { success: false, deletedEntries: 0, error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Lås upp eller öppna räkenskapsåret först under Inställningar > Bokföring > Räkenskapsår.', code: 'period_locked' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,7 @@ export async function undoSIEImport(
|
||||
.single()
|
||||
|
||||
if (period?.is_closed || period?.locked_at) {
|
||||
return { success: false, deletedEntries: 0, error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.' }
|
||||
return { success: false, deletedEntries: 0, error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Lås upp eller öppna räkenskapsåret först under Inställningar > Bokföring > Räkenskapsår.' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user