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
@@ -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 },
)