79013cf092
* 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>
292 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|