Bug/year end numbers (#744)
* fix(bookkeeping): allow creating a fiscal year that fills an interior gap Fiscal-period creation only allowed chaining a new räkenskapsår before the earliest or after the latest existing period, so a company with a gap between years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the missing year — it failed with "New period must chain before the earliest or after the latest existing period". Generalise forward chaining onto the new period's immediate predecessor, which covers both appending a new latest year and filling an interior gap. The "prior year must be locked" guard now applies only to true appends, not gap fills (a backfill, like backward chaining). previous_period_id is set to the predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain stays intact. The create dialog suggests the missing year (capped so it never overlaps the next period), the settings page seeds the dialog at the earliest gap, and the default suggested name is now "Räkenskapsår <year>". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end Year-end closing posts the result to 2099 "Årets resultat" and the opening balance carried it forward on 2099 every year, so 2099 accumulated across years and the prior result never moved off "Årets resultat". executeYearEndClosing now posts a separate "Omföring av föregående års resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss) into the new period after the continuity check passes, so 2099 starts each year at zero. Kept as a standalone entry rather than folded into the opening balance so the IB stays a faithful mirror of the prior UB and IB/UB continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat. The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally left to a separate step. - new source_type 'result_appropriation' (migration + type + Zod enum) - generateResultAppropriation helper (planner + poster) wired as step 11 - ResultStep surfaces the omföring voucher - unit tests + pg-real invariant - scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): shadow-detect date-drift duplicate bank transactions The content-dedup bridge buckets on exact (date, ore), so the same transaction re-imported with a booking date that drifted a day lands in a different bucket and slips past every dedup layer. Add a measure-only ("shadow") detector that flags would-be +/-1-day duplicates and counts them, without changing what is inserted - so the gap can be validated on real data before any enforcement, mirroring the scope-drift shadow. - shiftIsoDate(): pure, deterministic adjacent-date helper - ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket snapshot, per-row gate with desc-bridge + cross-channel-symmetry signals; logs shadow_date_drift_candidates, never alters inserts - fail-safe date guard so the measurement can never abort an import - regression tests for both signals, account/window/distinct guards, no-double-count, and the malformed-date fail-safe Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(bookkeeping): anonymize a customer reference in fiscal-period tests Remove a real customer name ("AXMD AB") from regression-test comments; no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workflows): enhance Docker image scanning and caching mechanisms * fix(bookkeeping): enhance year-end result appropriation handling and error reporting --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5c40fa9aeb
commit
2a8bf9b42e
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Lock } from 'lucide-react'
|
||||
import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -42,58 +43,6 @@ function errorMessage(err: unknown, fallback = 'Ett oväntat fel uppstod.'): str
|
||||
return fallback
|
||||
}
|
||||
|
||||
function computeSuggestedPeriod(entryDate: string, periods: FiscalPeriod[]) {
|
||||
if (periods.length === 0) {
|
||||
// No periods at all — suggest a calendar year period around the entry date
|
||||
const year = entryDate.split('-')[0]
|
||||
return {
|
||||
name: `FY ${year}`,
|
||||
period_start: `${year}-01-01`,
|
||||
period_end: `${year}-12-31`,
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
const earliest = sorted[0]
|
||||
const latest = sorted[sorted.length - 1]
|
||||
|
||||
if (entryDate < earliest.period_start) {
|
||||
// Backward: end = day before earliest start, start = 12 months back, 1st of month
|
||||
// Use UTC throughout — local-time Date math + toISOString() shifts dates by
|
||||
// the timezone offset (e.g. CET produces 2024-12-31 → 2025-12-30).
|
||||
const end = new Date(earliest.period_start + 'T00:00:00Z')
|
||||
end.setUTCDate(end.getUTCDate() - 1)
|
||||
|
||||
const start = new Date(end)
|
||||
start.setUTCMonth(start.getUTCMonth() - 11)
|
||||
start.setUTCDate(1)
|
||||
|
||||
const startStr = start.toISOString().split('T')[0]
|
||||
const endStr = end.toISOString().split('T')[0]
|
||||
const startYear = start.getUTCFullYear()
|
||||
const endYear = end.getUTCFullYear()
|
||||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||||
|
||||
return { name, period_start: startStr, period_end: endStr }
|
||||
}
|
||||
|
||||
// Forward: start = day after latest end, end = 12 months later (last day of month)
|
||||
const start = new Date(latest.period_end + 'T00:00:00Z')
|
||||
start.setUTCDate(start.getUTCDate() + 1)
|
||||
|
||||
const end = new Date(start)
|
||||
end.setUTCMonth(end.getUTCMonth() + 12)
|
||||
end.setUTCDate(0) // Last day of previous month
|
||||
|
||||
const startStr = start.toISOString().split('T')[0]
|
||||
const endStr = end.toISOString().split('T')[0]
|
||||
const startYear = start.getUTCFullYear()
|
||||
const endYear = end.getUTCFullYear()
|
||||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||||
|
||||
return { name, period_start: startStr, period_end: endStr }
|
||||
}
|
||||
|
||||
export default function CreatePeriodDialog({ open, onOpenChange, entryDate, periods, onCreated }: Props) {
|
||||
const { toast } = useToast()
|
||||
const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods])
|
||||
|
||||
@@ -70,10 +70,34 @@ export function ResultStep({ result }: ResultStepProps) {
|
||||
value={formatVoucher(result.openingBalanceEntry)}
|
||||
href={`/bookkeeping/${result.openingBalanceEntry.id}`}
|
||||
/>
|
||||
{result.resultAppropriationEntry && (
|
||||
<ResultRow
|
||||
label="Omföring av föregående års resultat (2099 → 2098)"
|
||||
value={formatVoucher(result.resultAppropriationEntry)}
|
||||
href={`/bookkeeping/${result.resultAppropriationEntry.id}`}
|
||||
/>
|
||||
)}
|
||||
<ResultRow label="Ny räkenskapsperiod" value={result.nextPeriod.name} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.resultAppropriationFailed && (
|
||||
<Card className="border-destructive/30 bg-destructive/5">
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive shrink-0" />
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">
|
||||
Omföringen av föregående års resultat (2099 → 2098) kunde inte bokföras.
|
||||
</span>{' '}
|
||||
Bokslutet och de ingående balanserna är klara, men konto 2099 “Årets
|
||||
resultat” bär fortfarande föregående års resultat in i den nya perioden.
|
||||
Det måste flyttas till 2098 innan balansräkningen stämmer. Kör om bokslutet
|
||||
eller kontakta support — felet är loggat.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{continuity && (
|
||||
<ContinuityPanel
|
||||
discrepancies={discrepancies}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Plus } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
|
||||
/** Status of a fiscal period, in legal precedence: closed > locked > open. */
|
||||
function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
|
||||
@@ -23,22 +24,6 @@ const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warnin
|
||||
open: 'success',
|
||||
}
|
||||
|
||||
/** ISO date one day after the latest period ends — seeds the create dialog so
|
||||
* its suggestion chains forward onto the most recent year. UTC throughout to
|
||||
* avoid timezone-offset date drift. */
|
||||
function nextEntryDate(periods: FiscalPeriod[]): string {
|
||||
if (periods.length === 0) {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
const latestEnd = periods
|
||||
.map((p) => p.period_end)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.at(-1)!
|
||||
const d = new Date(latestEnd + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + 1)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function FiscalYearsManager() {
|
||||
const t = useTranslations('settings_bookkeeping')
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
@@ -115,7 +100,7 @@ export function FiscalYearsManager() {
|
||||
<CreatePeriodDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
entryDate={nextEntryDate(periods)}
|
||||
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user