From 79013cf0921f67ce1b8a5b35adf589596c74e434 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:36:18 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + app/(dashboard)/import/page.tsx | 16 +- .../[id]/reset/__tests__/route.test.ts | 262 +++++++ .../fiscal-periods/[id]/reset/route.ts | 103 +++ components/bookkeeping/JournalEntryList.tsx | 16 + components/settings/FiscalYearResetDialog.tsx | 291 ++++++++ components/settings/FiscalYearsManager.tsx | 28 +- lib/api/schemas.ts | 15 + .../__tests__/fiscal-year-reset.test.ts | 167 +++++ lib/core/bookkeeping/fiscal-year-reset.ts | 105 +++ lib/errors/structured-errors.ts | 32 + lib/reports/behandlingshistorik.ts | 23 + messages/en.json | 31 + messages/sv.json | 31 + .../20260825150000_reset_fiscal_year.sql | 650 ++++++++++++++++++ tests/pg/reset-fiscal-year.pg.test.ts | 540 +++++++++++++++ types/index.ts | 48 ++ 17 files changed, 2359 insertions(+), 2 deletions(-) create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/reset/__tests__/route.test.ts create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/reset/route.ts create mode 100644 components/settings/FiscalYearResetDialog.tsx create mode 100644 lib/core/bookkeeping/__tests__/fiscal-year-reset.test.ts create mode 100644 lib/core/bookkeeping/fiscal-year-reset.ts create mode 100644 supabase/migrations/20260825150000_reset_fiscal_year.sql create mode 100644 tests/pg/reset-fiscal-year.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index e1cbb6e6..d9f145ac 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1217,12 +1217,15 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark. [2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway. [2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden. +[2026-08-25] reset_fiscal_year (#1883) catches foreign_key_violation and aborts instead of enumerating every table that references journal_entries: RESTRICT/NO ACTION FKs (assets, accruals, salary runs) fail the whole reset with FISCAL_YEAR_RESET_LINKED_ENTRIES, SET NULL FKs (transactions, invoice payments) unlink as their designers intended. Future FKs stay covered without touching the RPC. +[2026-08-25] Fiscal-year reset treats a posted vat_settlement verifikat, SKV declaration lock/submit audit rows and skatteverket-extension submission_* keys as "VAT declared" evidence and refuses: SKV's actual declaration state is not observable from the DB, so every local trace fails closed (same posture as company_migration_reset 20260818224000). [2026-08-25] Proposal prefill/preview (lib/bookkeeping/proposal-lines.ts) mirrors the ENGINE's formulas, not the historical preview: net leg = gross minus single-rounded VAT (independently rounded extractNet/extractVat unbalances 12% grosses at 14 mod 28 ore), plain Math.round ore parity via a local engineRound (roundOre's EPSILON nudge diverges from booked verifikat at exact-half floats like 8.62*0.25), legacy counterparty proposals now render the 2645/2614 fiktiv-moms pair the legacy booking path actually emits (the 2026-07-29 decision claimed preview/engine parity but the preview omitted the pair; once 'Andra rader' made the preview bookable, the omission would book RC expenses without fiktiv moms), sign-mismatched counterparty matches are mirrored like the server, static template accounts are entity-resolved (_ab), the 'none' VAT sentinel is resolved via resolveExplicitVat before line computation, and the settlement swap applies only to a literal 1930 leg (applySettlementAccount parity). Chosen over sourcing the prefill from MappingResult builders directly to keep the client dialog free of server-only inputs; skeptic counterexamples are locked in proposal-lines.test.ts. [2026-08-24] gnubok_create_company (#1814 PR 3) is a direct write with an explicit two-phase confirm (preview, then confirm=true) instead of a staged pending_operation: pending_operations rows are company-scoped and there is no company to attach the staging to before it exists. [2026-08-24] gnubok_connect_bank / gnubok_connect_skatteverket hand the user a browser link plus connection status instead of driving the PSD2 or Skatteverket flow from the MCP server: both extension handlers need a cookie session and BankID in a browser, and cross-extension imports are forbidden. The web app's /import?mode=psd2 and the skatteverket authorize route are the links. [2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling. [2026-08-25] gnubok_create_company / POST /api/v1/companies require f_skatt explicitly and org_number whenever vat_registered (review findings on #1864): a defaulted F-skatt approval or a VAT-registered company with no momsregistreringsnummer would flow straight into invoices (ML 17 kap 24 §, SE-R-005). Explicit beats convenient on a legal fact. [2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension. +[2026-08-25] reset_fiscal_year skeptic hardening (#1897): filed ROT/RUT begaran and cross-year rattelse/storno chains BLOCK the reset (new snapshot guards) instead of being silently unlinked or crashing on the RI UPDATE; other SET NULL links (invoices, payments, transactions) unlink by design with dialog disclosure, since they are re-bookable and the year's external reliance states all refuse. Full verifikat content is archived in company-scoped RESET_SNAPSHOT audit rows before deletion: the trigger's line-level audit rows carry no company_id and header rows carry no amounts, which would otherwise destroy rakenskapsinformation (BFL 7 kap). [2026-08-24] The OAuth AS metadata advertises client_id_metadata_document_supported (CIMD, #1814 PR 4) without fetching or validating the client's metadata document: authorize/token never keyed anything on client_id, the redirect_uri allowlist is the trust boundary, and CIMD only changes what Claude/Codex send as client_id (an HTTPS URL instead of a DCR-minted UUID). Fetching the document would add a network dependency to every consent for no gain in this design. DCR stays for ChatGPT. [2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile. [2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift). diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index d13a77e5..c7a21e61 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -2370,6 +2370,20 @@ export default function ImportPage() { if (viewParam === 'export' || viewParam === 'import') { setView(viewParam) } + // Deep link from surfaces where a bad import is discovered (e.g. the + // voucher list): /import?history=sie lands directly on the fold-open + // SIE import history so "Ångra import" is one click away. + if (searchParams.get('history') === 'sie') { + setView('import') + setSieHistoryOpen(true) + // The history table renders below ~8 import rows: scroll it into view + // (same pattern as the #cloud-backup hash deep link below). + setTimeout(() => { + document + .getElementById('sie-import-history') + ?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + }, 80) + } }, [isSandbox, searchParams]) // Hash-based deep links: all live on the export tab; #sie-export opens @@ -2547,7 +2561,7 @@ export default function ImportPage() { /> {sieHistoryOpen && ( -
+
)} diff --git a/app/api/bookkeeping/fiscal-periods/[id]/reset/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/reset/__tests__/route.test.ts new file mode 100644 index 00000000..cde16f08 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/reset/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/reset/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/reset/route.ts new file mode 100644 index 00000000..48e6de7d --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/reset/route.ts @@ -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( + '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( + '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 }, +) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 71145a9e..2597620a 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -31,6 +31,7 @@ import { ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE, } from '@/components/common/FiscalYearSelector' import { FyPicker } from '@/components/common/FyPicker' +import { AttnLine } from '@/components/ui/attn-line' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { OpenInNewTab } from '@/components/ui/open-in-new-tab' import { @@ -1441,6 +1442,21 @@ export default function JournalEntryList({
)} + {/* Discoverability for "Ångra import" (issue #1883): when the page + shows import-sourced vouchers, point at the SIE import history + where a bad import can be undone in one step. One page-domain + attn line (design convention 6). Only source_type 'import' is an + SIE marker: 'opening_balance' is also written by year-end closing + and the manual IB flows, which have nothing to undo here. */} + {entries.some((e) => e.source_type === 'import') && ( + + {t('import_attn')} + + )} +
diff --git a/components/settings/FiscalYearResetDialog.tsx b/components/settings/FiscalYearResetDialog.tsx new file mode 100644 index 00000000..7f84bd46 --- /dev/null +++ b/components/settings/FiscalYearResetDialog.tsx @@ -0,0 +1,291 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { AlertTriangle, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { formatDate } from '@/lib/utils' +import type { FiscalYearResetBlocker, FiscalYearResetEligibility } from '@/types' + +interface FiscalYearResetDialogProps { + periodId: string + periodName: string + open: boolean + onOpenChange: (open: boolean) => void + /** Called after a successful reset so the parent can refetch. */ + onReset: () => void +} + +function readApiError(body: unknown, fallback: string): string { + if (!body || typeof body !== 'object') return fallback + const error = (body as { error?: unknown }).error + if (typeof error === 'string') return error + if (error && typeof error === 'object') { + const message = (error as { message?: unknown }).message + if (typeof message === 'string') return message + } + return fallback +} + +/** + * Destructive fiscal-year reset (issue #1883): hard-deletes ALL vouchers in + * one OPEN fiscal year after typed confirmation of the year's label. Mirrors + * the CompanyMigrationResetDialog pattern: eligibility preview with blockers, + * a clear statement of what is deleted (voucher count, year label) and what + * is NOT (documents are detached, never deleted: BFL 7 kap), and a + * type-the-name confirmation. Every guard is re-enforced server-side. + */ +export function FiscalYearResetDialog({ + periodId, + periodName, + open, + onOpenChange, + onReset, +}: FiscalYearResetDialogProps) { + const t = useTranslations('settings_bookkeeping') + const { toast } = useToast() + const loadFailedMessage = t('fy_reset_load_failed') + const [eligibility, setEligibility] = useState(null) + const [loadError, setLoadError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isResetting, setIsResetting] = useState(false) + const [confirmName, setConfirmName] = useState('') + + useEffect(() => { + if (!open) return + + let cancelled = false + async function loadEligibility() { + setIsLoading(true) + setLoadError(null) + setEligibility(null) + try { + const response = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/reset`, + { cache: 'no-store' }, + ) + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(readApiError(body, loadFailedMessage)) + } + if (!cancelled) setEligibility(body.data as FiscalYearResetEligibility) + } catch (error) { + if (!cancelled) { + setLoadError( + error instanceof Error ? getUserErrorMessage(error) : loadFailedMessage, + ) + } + } finally { + if (!cancelled) setIsLoading(false) + } + } + + void loadEligibility() + return () => { + cancelled = true + } + }, [periodId, loadFailedMessage, open]) + + function resetForm() { + setEligibility(null) + setLoadError(null) + setConfirmName('') + } + + function handleOpenChange(nextOpen: boolean) { + if (isResetting) return + onOpenChange(nextOpen) + if (!nextOpen) resetForm() + } + + function blockerMessage(blocker: FiscalYearResetBlocker): string { + switch (blocker.code) { + case 'period_closed': + return t('fy_reset_blocker_closed') + case 'period_locked': + return t('fy_reset_blocker_locked') + case 'company_lock_date': + return t('fy_reset_blocker_lock_date', { date: blocker.date ?? '' }) + case 'year_end_state': + return t('fy_reset_blocker_year_end') + case 'arsredovisning_state': + return t('fy_reset_blocker_arsredovisning') + case 'next_year_dependency': + return t('fy_reset_blocker_next_year') + case 'vat_declared': + return t('fy_reset_blocker_vat') + case 'agi_declared': + return t('fy_reset_blocker_agi') + case 'rot_rut_state': + return t('fy_reset_blocker_rot_rut') + case 'cross_year_reference': + return t('fy_reset_blocker_cross_year') + default: + return t('fy_reset_blocker_other') + } + } + + const confirmationName = eligibility?.period.name ?? periodName + const canReset = + eligibility?.eligible === true && + confirmName.trim() === confirmationName.trim() && + !isResetting + + async function handleReset() { + if (!canReset) return + setIsResetting(true) + try { + const response = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/reset`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirm_name: confirmName }), + }, + ) + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(readApiError(body, t('fy_reset_failed_default'))) + } + + const deleted = (body.data as { deleted?: number } | undefined)?.deleted ?? 0 + toast({ + title: t('fy_reset_success_title'), + description: t('fy_reset_success_description', { count: deleted }), + }) + setIsResetting(false) + onOpenChange(false) + resetForm() + onReset() + } catch (error) { + toast({ + title: t('fy_reset_failed_title'), + description: + error instanceof Error + ? getUserErrorMessage(error) + : t('fy_reset_failed_default'), + variant: 'destructive', + }) + setIsResetting(false) + } + } + + return ( + + + + {t('fy_reset_dialog_title', { name: periodName })} + {t('fy_reset_dialog_description')} + + + {isLoading ? ( +
+ + {t('fy_reset_checking')} +
+ ) : loadError ? ( +
+ {loadError} +
+ ) : eligibility ? ( +
+ {eligibility.blockers.length > 0 ? ( +
+
+ + {t('fy_reset_blocked_title')} +
+
    + {eligibility.blockers.map((blocker) => ( +
  • {blockerMessage(blocker)}
  • + ))} +
+
+ ) : null} + +
+

{t('fy_reset_summary_heading')}

+
+
+
{t('fy_reset_summary_year')}
+
+ {eligibility.period.name} + + {formatDate(eligibility.period.period_start)} -{' '} + {formatDate(eligibility.period.period_end)} + +
+
+
+
{t('fy_reset_summary_vouchers')}
+
{eligibility.counts.vouchers}
+
+
+
{t('fy_reset_summary_documents')}
+
+ {eligibility.counts.documents_to_detach} +
+
+
+

+ {t('fy_reset_documents_note')} +

+
+ + {eligibility.eligible ? ( +
+ + setConfirmName(event.target.value)} + placeholder={confirmationName} + autoComplete="off" + /> +
+ ) : null} +
+ ) : null} + + + + {eligibility?.eligible ? ( + + ) : null} + +
+
+ ) +} diff --git a/components/settings/FiscalYearsManager.tsx b/components/settings/FiscalYearsManager.tsx index 5d8f3866..3213c54e 100644 --- a/components/settings/FiscalYearsManager.tsx +++ b/components/settings/FiscalYearsManager.tsx @@ -12,10 +12,11 @@ import { import { SettingsGroup } from '@/components/settings/SettingsRows' import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' -import { Plus, Lock, Unlock, Loader2 } from 'lucide-react' +import { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react' import { formatDate } from '@/lib/utils' import type { FiscalPeriod } from '@/types' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' +import { FiscalYearResetDialog } from '@/components/settings/FiscalYearResetDialog' import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -43,6 +44,7 @@ export function FiscalYearsManager() { const [hasError, setHasError] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) const [mutatingId, setMutatingId] = useState(null) + const [resetTarget, setResetTarget] = useState(null) // Only owners/admins may change a period's lock state. The API enforces this // too (requireWrite); this just hides controls a viewer/member can't use. @@ -162,6 +164,18 @@ export function FiscalYearsManager() { )} )} + {canManage && status === 'open' && ( + + )} {canManage && status === 'locked' && (