feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)

* 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:
Mattsson
2026-08-25 14:36:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 77cacdcf34
commit 79013cf092
17 changed files with 2359 additions and 2 deletions
@@ -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 },
)