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
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user