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
parent c981384a0e
commit dfed55cb6c
10 changed files with 403 additions and 7 deletions
+1
View File
@@ -1289,3 +1289,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent.
[2026-08-26] Removed the 1580 entry from ACCOUNT_DESCRIPTIONS and flipped AccountNumber name precedence to DB-name-first: the entry falsely labeled 1580 'Fordran for skatt' (tax receivables are 1640/1650; 1580 was traditionally card/coupon receivables, moved by BAS to 1686), and the hardcoded name silently overrode users' own kontoplan names. No replacement entry: 1580 is deliberately off-catalog, so companies with a legacy 1580 now see their own account name.
[2026-08-27] Cockpit auto-landing gated to byrå owner/admin (isCockpitLandingRole; landing route + '/' bounce), superseding the 2026-08-05 all-members widening: plain members land like regular users and open the cockpit from the nav; the middleware zero-company steer stays ungated because a member with no client companies has nowhere else to land. Allowlist over role!=='member' so future roles default to the regular landing.
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
@@ -0,0 +1,111 @@
/**
* Tests for POST /api/bookkeeping/fiscal-periods/[id]/reopen-external
* (undo "klarmarkera": reopen a period marked closed in a previous system).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
reopenExternallyClosedPeriod: vi.fn(),
}))
import { requireAuth } from '@/lib/auth/require-auth'
import { reopenExternallyClosedPeriod } from '@/lib/core/bookkeeping/period-service'
import { POST } from '../route'
const mockReopen = vi.mocked(reopenExternallyClosedPeriod)
function reopenRequest(): Request {
return createMockRequest('/api/bookkeeping/fiscal-periods/p1/reopen-external', {
method: 'POST',
})
}
function mockAuth() {
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
user: { id: 'user-1' },
supabase: {},
error: null,
})
}
beforeEach(() => {
vi.clearAllMocks()
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/reopen-external', () => {
it('returns 401 when not authenticated', async () => {
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(reopenRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(401)
expect(mockReopen).not.toHaveBeenCalled()
})
it('returns 403 when the caller lacks write permission', async () => {
mockAuth()
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const res = await POST(reopenRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(403)
expect(mockReopen).not.toHaveBeenCalled()
})
it('reopens the period and returns it on success', async () => {
mockAuth()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockReopen.mockResolvedValue({ id: 'p1', is_closed: false, closed_externally: false, locked_at: null } as any)
const res = await POST(reopenRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.id).toBe('p1')
expect(body.data.is_closed).toBe(false)
expect(mockReopen).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'p1')
})
it('maps a missing period to 404', async () => {
mockAuth()
mockReopen.mockRejectedValue(new Error('Fiscal period not found'))
const res = await POST(reopenRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_NOT_FOUND')
})
it('maps an open period to a 409', async () => {
mockAuth()
mockReopen.mockRejectedValue(new Error('Period is not closed'))
const res = await POST(reopenRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_REOPEN_NOT_CLOSED')
})
it('maps a period closed by a year-end run to a 409', async () => {
mockAuth()
mockReopen.mockRejectedValue(
new Error('Period was closed with a year-end run in Accounted and cannot be reopened here'),
)
const res = await POST(reopenRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_REOPEN_NOT_EXTERNAL')
})
})
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { reopenExternallyClosedPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
// Undo "klarmarkera": reopen a period that was marked as closed in a previous
// bookkeeping system. Structured envelope like the sibling lock/unlock routes
// (FiscalYearsManager surfaces error.message verbatim).
export const POST = withRouteContext(
'period.reopen_external',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
try {
const period = await reopenExternallyClosedPeriod(supabase, companyId!, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
opLog.error('failed to reopen externally closed period', err as Error)
// reopenExternallyClosedPeriod() throws plain Error: "Fiscal period not
// found", "Period is not closed", "Period was closed with a year-end
// run ...". Translate to envelope codes, mirroring the unlock route.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
if (/not closed/i.test(message)) {
return errorResponseFromCode('PERIOD_REOPEN_NOT_CLOSED', opLog, { requestId })
}
if (/year-end run/i.test(message)) {
return errorResponseFromCode('PERIOD_REOPEN_NOT_EXTERNAL', opLog, { requestId })
}
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
)
+50 -3
View File
@@ -59,7 +59,10 @@ export function FiscalYearsManager() {
// Newest first: matches the API's ordering and reads most-recent-at-top.
const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start))
async function runLockAction(period: FiscalPeriod, action: 'lock' | 'unlock') {
async function runLockAction(
period: FiscalPeriod,
action: 'lock' | 'unlock' | 'reopen-external',
) {
setMutatingId(period.id)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}/${action}`, {
@@ -71,7 +74,14 @@ export function FiscalYearsManager() {
// saknar bokföring", which tells the user exactly what to fix first.
throw new Error(body?.error?.message || t('fy_action_error'))
}
toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') })
toast({
title:
action === 'lock'
? t('fy_lock_success')
: action === 'unlock'
? t('fy_unlock_success')
: t('fy_reopen_success'),
})
await refreshPeriods()
} catch (err) {
toast({
@@ -106,6 +116,21 @@ export function FiscalYearsManager() {
if (ok) await runLockAction(period, 'unlock')
}
// Undo "klarmarkera" (year marked as closed in a previous bookkeeping
// system). Only offered while the closed state still comes from that mark:
// a year closed by a real year-end run keeps its closing entry and stays
// closed here.
async function handleReopen(period: FiscalPeriod) {
const ok = await confirm({
title: t('fy_reopen_confirm_title'),
description: t('fy_reopen_confirm_body', { name: period.name }),
confirmLabel: t('fy_action_reopen'),
cancelLabel: t('fy_confirm_cancel'),
variant: 'warning',
})
if (ok) await runLockAction(period, 'reopen-external')
}
return (
<SettingsGroup label={t('fy_heading')} help={t('fy_help')}>
{isLoading ? (
@@ -122,6 +147,8 @@ export function FiscalYearsManager() {
sorted.map((p) => {
const status = periodStatus(p)
const isMutating = mutatingId === p.id
const closedExternally = status === 'closed' && p.closed_externally === true
const canReopen = canManage && closedExternally && !p.closing_entry_id
return (
<div key={p.id} className="flex items-center gap-3 border-b border-border px-1 py-3">
<div className="min-w-0 flex-1">
@@ -134,7 +161,9 @@ export function FiscalYearsManager() {
{status === 'open' ? (
<span className="text-xs text-muted-foreground">{t('fy_status_open')}</span>
) : (
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
<Badge variant={STATUS_VARIANT[status]}>
{closedExternally ? t('fy_status_closed_external') : t(`fy_status_${status}`)}
</Badge>
)}
{canManage && status === 'open' && (
<Button
@@ -184,6 +213,24 @@ export function FiscalYearsManager() {
)}
</Button>
)}
{canReopen && (
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={isMutating}
onClick={() => handleReopen(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Unlock className="mr-1.5 h-4 w-4" />
{t('fy_action_reopen')}
</>
)}
</Button>
)}
</div>
</div>
)
@@ -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({
+100
View File
@@ -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).
+12 -2
View File
@@ -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,
+2 -2
View File
@@ -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.' }
}
}
+5
View File
@@ -1969,6 +1969,11 @@
"fy_lock_success": "Fiscal year locked",
"fy_unlock_success": "Fiscal year unlocked",
"fy_action_error": "The action could not be completed",
"fy_status_closed_external": "Closed in previous system",
"fy_action_reopen": "Reopen",
"fy_reopen_confirm_title": "Reopen fiscal year?",
"fy_reopen_confirm_body": "{name} was marked as closed in a previous bookkeeping system. The mark is removed and the year is opened and unlocked so you can replace the SIE import or post additions. Mark it as closed again when you are done. This action is recorded in the audit log.",
"fy_reopen_success": "Fiscal year reopened",
"fy_action_reset": "Reset",
"fy_reset_dialog_title": "Reset fiscal year {name}?",
"fy_reset_dialog_description": "All vouchers in the fiscal year are permanently deleted, regardless of how they were created. Documents are never deleted, only detached. Invoices, payments and bank transactions that were linked to the vouchers become unbooked and need to be booked again. This cannot be undone and is recorded in the audit log.",
+5
View File
@@ -1969,6 +1969,11 @@
"fy_lock_success": "Räkenskapsåret är låst",
"fy_unlock_success": "Räkenskapsåret är upplåst",
"fy_action_error": "Åtgärden kunde inte slutföras",
"fy_status_closed_external": "Avslutat i tidigare program",
"fy_action_reopen": "Öppna igen",
"fy_reopen_confirm_title": "Öppna räkenskapsåret igen?",
"fy_reopen_confirm_body": "{name} markerades som avslutat i ett tidigare bokföringsprogram. Markeringen tas bort och året öppnas och låses upp, så att du kan ersätta SIE-importen eller bokföra kompletteringar. Klarmarkera året igen när du är klar. Åtgärden loggas i behandlingshistoriken.",
"fy_reopen_success": "Räkenskapsåret är öppet igen",
"fy_action_reset": "Nollställ",
"fy_reset_dialog_title": "Nollställ räkenskapsåret {name}?",
"fy_reset_dialog_description": "Alla verifikat i räkenskapsåret raderas permanent, oavsett hur de skapades. Underlag och dokument raderas aldrig, de kopplas bara loss. Fakturor, betalningar och banktransaktioner som var kopplade till verifikaten blir obokförda och behöver bokföras om. Åtgärden kan inte ångras och loggas i behandlingshistoriken.",