Files
accounted/components/settings/FiscalYearResetDialog.tsx
T
Mattsson 79013cf092 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>
2026-08-25 14:36:18 +02:00

292 lines
10 KiB
TypeScript

'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<FiscalYearResetEligibility | null>(null)
const [loadError, setLoadError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t('fy_reset_dialog_title', { name: periodName })}</DialogTitle>
<DialogDescription>{t('fy_reset_dialog_description')}</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('fy_reset_checking')}
</div>
) : loadError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{loadError}
</div>
) : eligibility ? (
<div className="space-y-5">
{eligibility.blockers.length > 0 ? (
<div className="space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
{t('fy_reset_blocked_title')}
</div>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{eligibility.blockers.map((blocker) => (
<li key={blocker.code}>{blockerMessage(blocker)}</li>
))}
</ul>
</div>
) : null}
<div>
<h3 className="mb-2 text-sm font-medium">{t('fy_reset_summary_heading')}</h3>
<dl className="divide-y divide-border border-y border-border">
<div className="flex items-center justify-between py-2 text-sm">
<dt className="text-muted-foreground">{t('fy_reset_summary_year')}</dt>
<dd className="font-medium">
{eligibility.period.name}
<span className="ml-2 text-muted-foreground tabular-nums">
{formatDate(eligibility.period.period_start)} -{' '}
{formatDate(eligibility.period.period_end)}
</span>
</dd>
</div>
<div className="flex items-center justify-between py-2 text-sm">
<dt className="text-muted-foreground">{t('fy_reset_summary_vouchers')}</dt>
<dd className="tabular-nums font-medium">{eligibility.counts.vouchers}</dd>
</div>
<div className="flex items-center justify-between py-2 text-sm">
<dt className="text-muted-foreground">{t('fy_reset_summary_documents')}</dt>
<dd className="tabular-nums font-medium">
{eligibility.counts.documents_to_detach}
</dd>
</div>
</dl>
<p className="mt-2 text-xs text-muted-foreground">
{t('fy_reset_documents_note')}
</p>
</div>
{eligibility.eligible ? (
<div className="space-y-2">
<Label htmlFor="fy-reset-name">
{t.rich('fy_reset_confirm_name', {
name: confirmationName,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="fy-reset-name"
value={confirmName}
onChange={(event) => setConfirmName(event.target.value)}
placeholder={confirmationName}
autoComplete="off"
/>
</div>
) : null}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isResetting}
>
{t('fy_confirm_cancel')}
</Button>
{eligibility?.eligible ? (
<Button variant="destructive" onClick={handleReset} disabled={!canReset}>
{isResetting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('fy_reset_resetting')}
</>
) : (
t('fy_reset_submit')
)}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}