* 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
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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') && (
|
||||
<AttnLine
|
||||
className="px-1 pb-2"
|
||||
action={{ label: t('import_attn_action'), href: '/import?history=sie' }}
|
||||
>
|
||||
{t('import_attn')}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
|
||||
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [resetTarget, setResetTarget] = useState<FiscalPeriod | null>(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() {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && status === 'open' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isMutating}
|
||||
onClick={() => setResetTarget(p)}
|
||||
>
|
||||
<Eraser className="mr-1.5 h-4 w-4" />
|
||||
{t('fy_action_reset')}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && status === 'locked' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -208,6 +222,18 @@ export function FiscalYearsManager() {
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
|
||||
{resetTarget && (
|
||||
<FiscalYearResetDialog
|
||||
periodId={resetTarget.id}
|
||||
periodName={resetTarget.name}
|
||||
open={resetTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResetTarget(null)
|
||||
}}
|
||||
onReset={fetchPeriods}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
</SettingsGroup>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user