* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) Two deliverables from the community report where a bad SIE test import left no way out short of deleting the company: A) Discoverability: the voucher list shows one attn line linking to /import?history=sie whenever the page contains import-sourced vouchers, and /import?history=sie deep-links straight into the fold-open SIE import history where per-import Angra already lives. B) Reset of an UNLOCKED fiscal year regardless of how the entries arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape hatch as undo_sie_import; no enforcement trigger touched) behind GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed type-the-year-name confirmation dialog on the fiscal years settings list. Refuses on: locked/closed year, company lock date over any part of the year, executed year-end, arsredovisning state, later year depending on this year's UB, VAT-declared evidence (vat_settlement verifikat, SKV lock/submit audit rows, extension workflow keys, fail closed) and AGI-declared months. Entries referenced by RESTRICT/NO ACTION FKs abort the whole reset (all-or-nothing). Documents are detached, never deleted (BFL 7 kap); every delete is audit-logged plus one behandlingshistorik summary row. Fixes #1883 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883) Blocking skeptic findings on PR #1897, one consolidated pass: - New snapshot blocker cross_year_reference: an entry outside the year whose correction_of_id / reverses_id / reversed_by_id points into the year made the delete crash with an uncaught P0001 (immutability trigger refusing the ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and silently severed draft chains. 12 such chains exist in prod today. - New snapshot blocker rot_rut_state: a begaran om utbetalning that reached Skatteverket (submitted/paid/partially_paid/rejected) was silently unlinked via SET NULL, erasing the bokforing behind a filed and possibly decided myndighetsarende. - Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit rows carry no company_id and header rows no amounts, so a reset destroyed konton/belopp with no company-readable trace. The RPC now archives the full content of every verifikat in company-scoped RESET_SNAPSHOT audit rows before deleting (action added to audit_log_action_check, NOT VALID), and behandlingshistorik renders them. - Dimension registry lockstep on reset (mirrors undo_sie_import): flipped imports can never be undone again, so their dimensions/values would have been orphaned forever. - EXCEPTION WHEN raise_exception now returns a typed FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500; gnubok.allow_delete is cleared before leaving the guarded block. - Voucher-list attn line fires only for source_type 'import': opening_balance is also written by year-end closing and the manual IB flows, which mislabelled every year-2+ company as SIE-imported. - /import?history=sie now scrolls the SIE history into view. - Reset dialog copy (sv+en) discloses that linked invoices, payments and bank transactions become unbooked; new blocker strings in both locales. - pg fixture fix: document_attachments seeded without company_id (23502); new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
77cacdcf34
commit
79013cf092
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Tests for GET/POST /api/bookkeeping/fiscal-periods/[id]/reset.
|
||||
*
|
||||
* Exercises the routes through the real withRouteContext wrapper, mocking its
|
||||
* auth/company/write dependencies and the fiscal-year-reset service. Covers:
|
||||
* 401, 403 viewer, validation 400, 404, the eligibility passthrough, the
|
||||
* happy path, and every refusal envelope (ineligible with blockers,
|
||||
* confirmation mismatch, linked entries).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
const getEligibilityMock = vi.fn()
|
||||
const resetFiscalYearMock = vi.fn()
|
||||
vi.mock('@/lib/core/bookkeeping/fiscal-year-reset', () => ({
|
||||
getFiscalYearResetEligibility: (...args: unknown[]) => getEligibilityMock(...args),
|
||||
resetFiscalYear: (...args: unknown[]) => resetFiscalYearMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
const routeParams = () => createMockRouteParams({ id: 'period-1' })
|
||||
|
||||
const ELIGIBILITY = {
|
||||
eligible: true,
|
||||
blockers: [],
|
||||
period: {
|
||||
id: 'period-1',
|
||||
name: '2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
},
|
||||
counts: { vouchers: 42, documents_to_detach: 3 },
|
||||
}
|
||||
|
||||
describe('GET /api/bookkeeping/fiscal-periods/[id]/reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(getEligibilityMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the eligibility preview', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: true, eligibility: ELIGIBILITY })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof ELIGIBILITY }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(ELIGIBILITY)
|
||||
expect(getEligibilityMock).toHaveBeenCalledWith(supabase, 'company-1', 'period-1')
|
||||
})
|
||||
|
||||
it('returns 404 when the period does not exist', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: false, code: 'FISCAL_YEAR_RESET_NOT_FOUND' })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 403 when the caller is not owner/admin', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_FORBIDDEN')
|
||||
})
|
||||
|
||||
it('maps unexpected codes to the generic 500 envelope', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: false, code: 'SOMETHING_ELSE' })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_FAILED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/fiscal-periods/[id]/reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
const postRequest = (body?: unknown) =>
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset', {
|
||||
method: 'POST',
|
||||
body: body ?? { confirm_name: '2026' },
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(resetFiscalYearMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(resetFiscalYearMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when confirm_name is missing', async () => {
|
||||
const response = await POST(postRequest({}), routeParams())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(resetFiscalYearMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('executes the reset and returns the counts', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: true,
|
||||
deleted: 42,
|
||||
detachedDocuments: 3,
|
||||
periodName: '2026',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { deleted: number; detachedDocuments: number; periodName: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ deleted: 42, detachedDocuments: 3, periodName: '2026' })
|
||||
expect(resetFiscalYearMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'period-1',
|
||||
'user-1',
|
||||
'2026',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 409 with blockers when the year is ineligible (e.g. locked)', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
blockers: [{ code: 'period_locked' }],
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { blockers?: Array<{ code: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_INELIGIBLE')
|
||||
expect(body.error.details?.blockers).toEqual([{ code: 'period_locked' }])
|
||||
})
|
||||
|
||||
it('returns 400 on confirmation mismatch', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest({ confirm_name: 'fel namn' }), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH')
|
||||
})
|
||||
|
||||
it('returns 409 when entries are linked to other records', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_LINKED_ENTRIES',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_LINKED_ENTRIES')
|
||||
})
|
||||
|
||||
it('returns 404 when the period does not exist', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_NOT_FOUND',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { FiscalYearResetSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
getFiscalYearResetEligibility,
|
||||
resetFiscalYear,
|
||||
} from '@/lib/core/bookkeeping/fiscal-year-reset'
|
||||
|
||||
type Params = { params: Promise<{ id: string }> }
|
||||
|
||||
// Hard-deleting a year of audit-logged journal entries (+ cascading lines)
|
||||
// can take well over the default function timeout. Match the SIE undo route
|
||||
// so the serverless function doesn't kill the request first.
|
||||
export const maxDuration = 300
|
||||
|
||||
const EXPECTED_CODES = new Set([
|
||||
'FISCAL_YEAR_RESET_NOT_FOUND',
|
||||
'FISCAL_YEAR_RESET_FORBIDDEN',
|
||||
'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH',
|
||||
'FISCAL_YEAR_RESET_LINKED_ENTRIES',
|
||||
])
|
||||
|
||||
function knownCode(code: string): string {
|
||||
return EXPECTED_CODES.has(code) ? code : 'FISCAL_YEAR_RESET_FAILED'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/fiscal-periods/[id]/reset
|
||||
*
|
||||
* Owner/admin-only, fail-closed eligibility preview for the fiscal-year
|
||||
* reset: which guards block it, and what a reset would delete (voucher
|
||||
* count) and detach (document links; the documents themselves are never
|
||||
* deleted, BFL 7 kap). The execution RPC rechecks every condition, so this
|
||||
* response is informational only.
|
||||
*/
|
||||
export const GET = withRouteContext<Params>(
|
||||
'period.reset.preview',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
const opLog = log.child({ periodId: id })
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, companyId!, id)
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(knownCode(result.code), opLog, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.eligibility })
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/fiscal-periods/[id]/reset
|
||||
*
|
||||
* Executes the reset: hard-deletes ALL vouchers in the open fiscal year.
|
||||
* Requires the year's label restated as typed confirmation (confirm_name);
|
||||
* refused past any lock/close/declared/year-end/next-year-dependency state.
|
||||
* All guards are enforced inside the reset_fiscal_year RPC.
|
||||
*/
|
||||
export const POST = withRouteContext<Params>(
|
||||
'period.reset.execute',
|
||||
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
const opLog = log.child({ periodId: id })
|
||||
|
||||
const validation = await validateBody(request, FiscalYearResetSchema, {
|
||||
log: opLog,
|
||||
operation: 'period.reset.execute',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await resetFiscalYear(
|
||||
supabase,
|
||||
companyId!,
|
||||
id,
|
||||
user.id,
|
||||
validation.data.confirm_name,
|
||||
)
|
||||
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(knownCode(result.code), opLog, {
|
||||
requestId,
|
||||
details: result.blockers ? { blockers: result.blockers } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
opLog.info('fiscal year reset executed', {
|
||||
deleted: result.deleted,
|
||||
detachedDocuments: result.detachedDocuments,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
deleted: result.deleted,
|
||||
detachedDocuments: result.detachedDocuments,
|
||||
periodName: result.periodName,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
Reference in New Issue
Block a user