Files
Jakob Wennberg 9618bab273 fix(bookkeeping): a following year's own IB no longer blocks nollställ, and a re-dated räkenskapsår gets the right name (#2286)
Customer report (Aisen & Adison AB, 2026-09-03): Fortnox years 2024-2026
imported first, then the first year 2022/2023 backfilled. Two bugs surfaced.

1. The backfilled year was saved as "Räkenskapsår 2027": CreatePeriodDialog
   seeds the next forward year and kept that name when the user re-dated the
   form. The name now follows the typed dates until the user edits the name
   (fiscalYearName exported from suggest-fiscal-period).

2. Nollställ of the backfilled year was refused with next_year_dependency
   because 2024 carried an opening-balance verifikat. Any IB in the next year
   counted as reliance, so a backfilled year could never be reset, while a
   next year WITHOUT an IB (whose balansrapport really rolls from this year)
   was allowed. Migration 20260904163000 redefines fiscal_year_reset_snapshot:
   the block fires only when the next year is locked, closed or has its own
   closing entry; a bokslut-generated IB is still refused via this year's
   closing_entry_id (year_end_state). The snapshot returns next_period
   {id, name, has_opening_balances} and the dialog states that the following
   year's IB stays as it is.

pg-real: reset-fiscal-year.pg.test.ts pins the narrowed guard (closed next
year, next year with closing entry, next year with its own IB survives the
reset untouched).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:49:23 +02:00

228 lines
8.6 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { computeSuggestedPeriod, fiscalYearName } from '@/lib/bookkeeping/suggest-fiscal-period'
import { fiscalPeriodAdvisoryText } from '@/lib/bookkeeping/fiscal-period-warnings'
import type { FiscalPeriod } from '@/types'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
entryDate: string
periods: FiscalPeriod[]
onCreated: () => void
}
/** A räkenskapsår that was just created, together with the non-blocking
* advisory the route attached to the 200. Only set when there is one. */
interface CreatedWithAdvisory {
name: string
periodStart: string
periodEnd: string
advisory: string
}
/** Read a user-facing message from either a legacy string error or the
* canonical { code, message } envelope. */
function errorMessage(err: unknown, fallback = 'Ett oväntat fel uppstod.'): string {
if (typeof err === 'string') return err
if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') {
return (err as { message: string }).message
}
return fallback
}
export default function CreatePeriodDialog({ open, onOpenChange, entryDate, periods, onCreated }: Props) {
const { toast } = useToast()
// The form copy below is pre-existing hardcoded Swedish and is left as it
// stands (out of scope here). Every string added for the created-state is
// keyed in both messages/sv.json and messages/en.json. The advisory itself is
// not keyed: the route emits one Swedish sentence and no English twin, and
// bokslut/räkenskapsår domain copy stays Swedish in both locales anyway
// (.claude/rules/i18n.md).
const t = useTranslations('bookkeeping')
const tCommon = useTranslations('common')
const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods])
const [name, setName] = useState(suggested.name)
// The name follows the dates until the user types a name of their own.
// Without this the seed suggestion (the next forward year, e.g.
// "Räkenskapsår 2027") survived the user re-dating the form to a backfilled
// first year and was saved verbatim.
const [nameEdited, setNameEdited] = useState(false)
const [periodStart, setPeriodStart] = useState(suggested.period_start)
const [periodEnd, setPeriodEnd] = useState(suggested.period_end)
const [isSubmitting, setIsSubmitting] = useState(false)
// Set when the period was created AND the route attached an advisory. The
// dialog then holds on a done-state so the sentence is actually read.
const [created, setCreated] = useState<CreatedWithAdvisory | null>(null)
// Reset form when suggested values change (dialog reopened with new date)
const [lastSuggested, setLastSuggested] = useState(suggested)
if (suggested.name !== lastSuggested.name || suggested.period_start !== lastSuggested.period_start) {
setName(suggested.name)
setNameEdited(false)
setPeriodStart(suggested.period_start)
setPeriodEnd(suggested.period_end)
setLastSuggested(suggested)
setCreated(null)
}
// A date input yields '' while incomplete and a full YYYY-MM-DD otherwise:
// only derive a name once both ends are known.
const updateDates = (start: string, end: string) => {
setPeriodStart(start)
setPeriodEnd(end)
if (!nameEdited && start && end) setName(fiscalYearName(start, end))
}
// Close, and refetch in the parent if this session created a period. The
// refetch is deferred to here on the advisory path on purpose: it swaps the
// `periods` prop, which resets the form above, and that would pull the
// advisory off screen before it was read.
const handleClose = () => {
const didCreate = created !== null
setCreated(null)
onOpenChange(false)
if (didCreate) onCreated()
}
const handleCreate = async () => {
setIsSubmitting(true)
try {
const res = await fetch('/api/bookkeeping/fiscal-periods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, period_start: periodStart, period_end: periodEnd }),
})
const result = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: errorMessage(result?.error),
variant: 'destructive',
})
return
}
// Every picker and form reads periods from the session cache
// (lib/reference-data): refresh it now so the new year is selectable
// everywhere at once, not only in the caller that gets onCreated.
void invalidateReferenceData('ref:fiscal-periods')
// The 200 may carry non-blocking advisories (today: a prior räkenskapsår
// still open, which is the normal state while the bokslut runs). The
// period WAS created, so this is information, never a failure: show the
// done-state with one ochre sentence rather than flashing a toast past.
const advisory = fiscalPeriodAdvisoryText(result)
if (advisory) {
setCreated({ name, periodStart, periodEnd, advisory })
return
}
toast({ title: 'Räkenskapsår skapat', description: `${name} har skapats.` })
onOpenChange(false)
onCreated()
} catch {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: 'Ett nätverksfel uppstod. Försök igen.',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleClose())}>
<DialogContent>
{created ? (
<>
<DialogHeader>
<DialogTitle>{t('period_created_title')}</DialogTitle>
<DialogDescription className="tabular-nums">
{t('period_created_range', {
name: created.name,
start: created.periodStart,
end: created.periodEnd,
})}
</DialogDescription>
</DialogHeader>
{/* Attention is one ochre sentence, not a banner (UI-migration
convention 6). The text is the route's own Swedish domain copy,
which stays Swedish in both locales. */}
<AttnLine>{created.advisory}</AttnLine>
<DialogFooter>
<Button onClick={handleClose}>{tCommon('close')}</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Skapa räkenskapsår</DialogTitle>
<DialogDescription>
Det finns inget räkenskapsår som täcker datumet {entryDate}. Skapa ett nytt nedan.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Namn</Label>
<Input
value={name}
onChange={(e) => {
setName(e.target.value)
setNameEdited(true)
}}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Startdatum</Label>
<Input type="date" value={periodStart} onChange={(e) => updateDates(e.target.value, periodEnd)} className="mt-1" />
</div>
<div>
<Label>Slutdatum</Label>
<Input type="date" value={periodEnd} onChange={(e) => updateDates(periodStart, e.target.value)} className="mt-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSubmitting || !name || !periodStart || !periodEnd}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}