2814d70cb4
* feat(bookkeeping): cascade opening-balance corrections to later years Fortnox/SIE migrations book one IB verifikat per imported year, so correcting one year's ingaende balans left every later year's linked IB carrying the stale figures (support case: a 2019 IB fixed in Fortnox after export never reached Accounted, skewing all subsequent saldon). - POST /api/import/opening-balance/correct accepts cascade: true and applies the correction's per-account delta to each subsequent year's IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts). Locked/closed/lock-dated/bokslut years are skipped and reported, never forced; a failed year is compensated and the cascade continues. - CorrectOpeningBalanceDialog offers the cascade as a default-checked checkbox when later years have their own IB verifikat, and when the current year is blocked it points at the earliest open year's IB verifikat instead of dead-ending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): atomic cascade replacement + review findings for PR #2076 - Cascade now books each later year through replaceOpeningBalanceEntry (one RPC transaction: storno + corrected voucher + pointer swap, CAS on the expected old entry), removing the create/reverse/relink window that could leave a period linked to a reversed IB entry. - Cascaded verifikat keep the original lines verbatim (descriptions and dimensions) and append labelled IB-rättelse adjustment lines per changed account instead of collapsing per-account nets. - Year-end lookup fails closed: a query error skips the period instead of reading as 'no bokslut'. - Dialog always sends the cascade flag (a cold reference cache no longer silently disables the default-on cascade), the success toast separates blocked years from failed years needing review, and the checkbox notes that a resultat correction may still need an omforing to 2091. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * feat(bookkeeping): Fortnox-style inline IB correction without storno Founder decision 2026-08-31: IB edits in open unlocked years should feel like Fortnox (change the number, no extra verifikat) instead of always producing a storno + rebook pair in serie A. - Migration 20260831150000 redefines correct_entry_lines_inline to admit source_type 'opening_balance' with three IB guards: only the period's current linked IB, no posted bokslut on the period, and replacement lines restricted to balance-sheet accounts (class 1-2). The entry id never changes, so fiscal_periods.opening_balance_entry_id stays valid and every report reads the corrected lines automatically. Storno, year_end and vat_settlement stay excluded; locked/closed/lock-dated periods are still refused (BFL 5 kap 5 par: storno is the only track there). - New POST /api/import/opening-balance/correct-inline: diff-based strike and replace inside the same IB verifikat, same OB_* pre-flight codes as the storno route, RPC rule violations surfaced verbatim as 409 OB_INLINE_REFUSED. With cascade: true the per-account delta is appended as labelled IB-rattelse lines inside each later open year's own IB verifikat (cascade mode 'inline'): a multi-year correction with zero new verifikat. - CorrectOpeningBalanceDialog computes the row diff (untouched lines keep ids, descriptions and dimensions) and posts to the inline route; copy updated (no storno language), toast reports inline updates. - In-app agent guidance (shared-rules) updated to describe the inline flow and the cascade checkbox. - Tests: pg-real suite for the redefined RPC (IB accept, linked-IB guard, bokslut guard, P&L guard, structural types still refused, non-IB unaffected), route tests, cascade inline-mode unit tests. The storno-based /correct route and engine paths are untouched: they remain for the import replace flow and API compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance The verifikation-draft period-lock gate test uses the literal 'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB bullet in shared-rules carried the same string in every prompt and broke the open-period assertion. Reference Bokföringslagen generically instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): derive inline cascade delta from the rattelse log Swedish-review finding on PR #2076: the cascade delta was computed from a route-side line snapshot read before the RPC, which a concurrent edit could theoretically desync from what the RPC actually committed. The delta now comes from the RPC's own journal_entry_rattelse_log row (struck_lines/added_lines snapshotted inside the RPC transaction), so the cascade always matches the committed base correction. Also softened the blocked-year guidance copy (declared-status is an assumption, not a verified fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): visible cascade failure + dimensions-aware no-op check CodeRabbit round-2 findings on PR #2076: - A cascade that failed to run (log fetch error, unexpected throw) was returned as an empty successful summary, so the dialog reported nothing wrong while later years stayed unverified. Both routes now mark it failed: true and the dialog tells the user to check later years' opening balances. - The RPC's no-op guard compared account/amount/description only, so a dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The comparison keys now include canonical dimensions jsonb text (fixed in the unmerged 20260831150000 migration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { getBasLoadedByNumber } from '@/lib/bookkeeping/bas-lazy'
|
|
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
|
|
import OpeningBalanceRowEditor, {
|
|
type EditableRow,
|
|
type OpeningBalanceEditorState,
|
|
} from '@/components/import/OpeningBalanceRowEditor'
|
|
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
|
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
|
import type { JournalEntry, JournalEntryLine } from '@/types'
|
|
|
|
interface Props {
|
|
/** The currently-linked, posted opening-balance verifikat being corrected. */
|
|
entry: JournalEntry
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onCorrected: () => void
|
|
}
|
|
|
|
interface CascadeSummary {
|
|
corrected: Array<{ fiscal_period_id: string; period_name: string | null }>
|
|
skipped: Array<{ fiscal_period_id: string; period_name: string | null; reason: string }>
|
|
/** The cascade itself failed to run: later years are unverified. */
|
|
failed?: boolean
|
|
}
|
|
|
|
/** Error codes where the year itself blocks the correction: guide the user
|
|
* to the earliest open year instead of leaving them at a dead end. */
|
|
const BLOCKED_PERIOD_CODES = new Set([
|
|
'OB_PERIOD_CLOSED',
|
|
'OB_PERIOD_LOCKED',
|
|
'OB_COMPANY_LOCK_DATE',
|
|
'OB_CORRECT_YEAR_END_EXISTS',
|
|
])
|
|
|
|
let seedIdCounter = 0
|
|
|
|
// Map the booked IB's lines into editable rows. account_name isn't stored on
|
|
// the line, so resolve it from BAS for display (cosmetic: only account_number
|
|
// + amounts are sent on save). The chart is a lazily loaded chunk: the
|
|
// caller re-seeds once it has arrived.
|
|
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
|
|
const lines = ((entry.lines || []) as JournalEntryLine[])
|
|
.slice()
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
|
|
return lines.map((l) => {
|
|
const bas = getBasLoadedByNumber(l.account_number)
|
|
return {
|
|
id: l.id || `seed_${++seedIdCounter}`,
|
|
account_number: l.account_number,
|
|
account_name: bas?.account_name ?? '',
|
|
debit_amount: Number(l.debit_amount) || 0,
|
|
credit_amount: Number(l.credit_amount) || 0,
|
|
validation_errors: [],
|
|
bas_match: bas?.account_name ?? null,
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Inline correction of an already-booked opening-balance verifikat. The user
|
|
* edits the IB's lines directly; on save we POST to
|
|
* /api/import/opening-balance/correct, which (BFL-compliant) stornoes the old
|
|
* IB, books a corrected one, and relinks the period to it. Works regardless of
|
|
* how the IB was created (SIE import, CSV/Excel import, or year-end carry).
|
|
*
|
|
* SIE migrations book one IB verifikat per imported year, so later years'
|
|
* saldon build on this one. When later years have their own IB verifikat the
|
|
* dialog offers to cascade the same change to them (checked by default); the
|
|
* server skips years that are locked, closed, or have a bokslut.
|
|
*/
|
|
export default function CorrectOpeningBalanceDialog({
|
|
entry,
|
|
open,
|
|
onOpenChange,
|
|
onCorrected,
|
|
}: Props) {
|
|
const { toast } = useToast()
|
|
const basReady = useBasReference()
|
|
// basReady is a re-seed trigger: names fill in once the chart chunk lands.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry, basReady])
|
|
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [cascade, setCascade] = useState(true)
|
|
const [showBlockedGuidance, setShowBlockedGuidance] = useState(false)
|
|
|
|
// Shared reference cache: drives the later-years checkbox and the
|
|
// blocked-year guidance. Best-effort: the dialog works while it loads
|
|
// (the server remains the authority on what is correctable).
|
|
const { periods } = useFiscalPeriods()
|
|
|
|
// A fresh open starts without the server-refusal guidance from a prior try.
|
|
useEffect(() => {
|
|
if (open) setShowBlockedGuidance(false)
|
|
}, [open])
|
|
|
|
const currentPeriod = useMemo(
|
|
() => periods.find((p) => p.id === entry.fiscal_period_id) ?? null,
|
|
[periods, entry.fiscal_period_id],
|
|
)
|
|
|
|
/** Later years with their own linked IB verifikat: the cascade targets. */
|
|
const laterPeriodsWithIB = useMemo(() => {
|
|
if (!currentPeriod) return []
|
|
return periods
|
|
.filter(
|
|
(p) =>
|
|
p.period_start > currentPeriod.period_start && p.opening_balance_entry_id !== null,
|
|
)
|
|
.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
|
}, [periods, currentPeriod])
|
|
|
|
/** Earliest year that still accepts corrections: the guidance target when
|
|
* this year is locked. Client-side approximation; the server re-checks. */
|
|
const earliestOpenPeriod = useMemo(() => {
|
|
return (
|
|
[...periods]
|
|
.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
|
.find((p) => !p.is_closed && !p.locked_at && p.opening_balance_entry_id !== null) ?? null
|
|
)
|
|
}, [periods])
|
|
|
|
const currentPeriodBlocked =
|
|
currentPeriod !== null && (currentPeriod.is_closed || currentPeriod.locked_at !== null)
|
|
|
|
const guidancePeriod =
|
|
earliestOpenPeriod && earliestOpenPeriod.id !== entry.fiscal_period_id
|
|
? earliestOpenPeriod
|
|
: null
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!state?.canSubmit || isSubmitting) return
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
// Diff the edited rows against the booked lines: only changed rows are
|
|
// struck and re-added (inline rättelse in the SAME verifikat, no
|
|
// storno). Untouched rows keep their ids, descriptions and dimensions.
|
|
const originalLines = ((entry.lines || []) as JournalEntryLine[])
|
|
const rowById = new Map(state.rows.map((r) => [r.id, r]))
|
|
|
|
const strike_line_ids: string[] = []
|
|
const new_lines: Array<{
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
line_description?: string
|
|
dimensions?: Record<string, string>
|
|
}> = []
|
|
|
|
for (const orig of originalLines) {
|
|
const row = orig.id ? rowById.get(orig.id) : undefined
|
|
if (!row || (row.debit_amount <= 0 && row.credit_amount <= 0)) {
|
|
// Row removed or zeroed out by the user: strike without replacement.
|
|
if (orig.id) strike_line_ids.push(orig.id)
|
|
continue
|
|
}
|
|
const changed =
|
|
row.account_number !== orig.account_number ||
|
|
row.debit_amount !== (Number(orig.debit_amount) || 0) ||
|
|
row.credit_amount !== (Number(orig.credit_amount) || 0)
|
|
if (changed && orig.id) {
|
|
strike_line_ids.push(orig.id)
|
|
new_lines.push({
|
|
account_number: row.account_number,
|
|
debit_amount: row.debit_amount,
|
|
credit_amount: row.credit_amount,
|
|
line_description:
|
|
row.account_number === orig.account_number
|
|
? orig.line_description ?? undefined
|
|
: `IB ${row.account_number}`,
|
|
dimensions: orig.dimensions,
|
|
})
|
|
}
|
|
}
|
|
|
|
const originalIds = new Set(originalLines.map((l) => l.id))
|
|
for (const row of state.rows) {
|
|
if (originalIds.has(row.id)) continue
|
|
if (row.debit_amount <= 0 && row.credit_amount <= 0) continue
|
|
new_lines.push({
|
|
account_number: row.account_number,
|
|
debit_amount: row.debit_amount,
|
|
credit_amount: row.credit_amount,
|
|
line_description: `IB ${row.account_number}`,
|
|
})
|
|
}
|
|
|
|
if (strike_line_ids.length === 0 && new_lines.length === 0) {
|
|
toast({ title: 'Inga ändringar att spara' })
|
|
setIsSubmitting(false)
|
|
return
|
|
}
|
|
|
|
const res = await fetch('/api/import/opening-balance/correct-inline', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
// cascade is ALWAYS sent (default on): if the reference cache has not
|
|
// loaded yet the checkbox is simply not shown, and omitting the flag
|
|
// in that window would silently leave later years stale. The server
|
|
// returns an empty cascade result when no later period exists.
|
|
body: JSON.stringify({
|
|
fiscal_period_id: entry.fiscal_period_id,
|
|
strike_line_ids,
|
|
new_lines,
|
|
cascade,
|
|
}),
|
|
})
|
|
|
|
const result = await res.json()
|
|
|
|
if (!res.ok) {
|
|
const err = new Error('Failed to correct opening balances') as Error & {
|
|
body?: unknown
|
|
status?: number
|
|
}
|
|
err.body = result
|
|
err.status = res.status
|
|
throw err
|
|
}
|
|
|
|
const cascadeSummary = (result?.data?.cascade ?? null) as CascadeSummary | null
|
|
let description = 'Beloppen uppdaterades direkt i verifikationen. Ingen ny verifikation skapades.'
|
|
if (cascadeSummary) {
|
|
const done = cascadeSummary.corrected.length
|
|
// Blocked (locked/closed/bokslut) and failed skips are different
|
|
// situations for the user: blocked is expected and needs no action
|
|
// here; failed means the year was left untouched and needs a look.
|
|
const blocked = cascadeSummary.skipped.filter(
|
|
(s) => s.reason === 'closed' || s.reason === 'locked' || s.reason === 'lock_date' || s.reason === 'year_end',
|
|
)
|
|
const failed = cascadeSummary.skipped.filter(
|
|
(s) => s.reason === 'correction_failed' || s.reason === 'validation_failed',
|
|
)
|
|
if (done > 0) {
|
|
description += ` ${done} senare räkenskapsår uppdaterades också.`
|
|
}
|
|
if (blocked.length > 0) {
|
|
const names = blocked.map((s) => s.period_name).filter(Boolean).join(', ')
|
|
description += ` ${blocked.length} år hoppades över (låsta, stängda eller med bokslut)${names ? `: ${names}` : ''}.`
|
|
}
|
|
if (failed.length > 0) {
|
|
const names = failed.map((s) => s.period_name).filter(Boolean).join(', ')
|
|
description += ` ${failed.length} år kunde inte uppdateras och behöver kontrolleras${names ? `: ${names}` : ''}.`
|
|
}
|
|
if (cascadeSummary.failed) {
|
|
description +=
|
|
' Uppdateringen av senare räkenskapsår kunde inte genomföras: kontrollera deras ingående balanser.'
|
|
}
|
|
}
|
|
|
|
toast({ title: 'Ingående balanser korrigerade', description })
|
|
// The correction relinks fiscal_periods.opening_balance_entry_id (for
|
|
// every cascaded year too): refresh the shared reference cache.
|
|
await invalidateReferenceData('ref:fiscal-periods')
|
|
onOpenChange(false)
|
|
onCorrected()
|
|
} catch (err) {
|
|
const anyErr = err as { body?: unknown; status?: number }
|
|
const code = (anyErr.body as { error?: { code?: string } } | undefined)?.error?.code
|
|
if (code && BLOCKED_PERIOD_CODES.has(code)) {
|
|
setShowBlockedGuidance(true)
|
|
}
|
|
toast({
|
|
title: 'Kunde inte korrigera ingående balanser',
|
|
description: getErrorMessage(anyErr.body ?? err, {
|
|
context: 'journal_entry',
|
|
statusCode: anyErr.status,
|
|
}),
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}, [state, isSubmitting, entry.fiscal_period_id, entry.lines, cascade, toast, onOpenChange, onCorrected])
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Korrigera ingående balanser</DialogTitle>
|
|
<DialogDescription>
|
|
Ändra beloppen nedan och spara. Verifikationen (
|
|
<span data-ph-mask="">{formatVoucher(entry)}</span>) uppdateras direkt: ingen ny
|
|
verifikation skapas.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Inline rättelse (BFL 5 kap 5 §): edited in place, original logged */}
|
|
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Ändringen sparas som en spårbar rättelse i samma verifikation (Bokföringslagen 5 kap
|
|
5 §): de ursprungliga raderna bevaras i rättelseloggen.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Blocked-year guidance: shown when this year is (or the server says
|
|
it is) locked, closed, or has a bokslut. Instead of a dead end,
|
|
point at the earliest year that still accepts corrections. */}
|
|
{(currentPeriodBlocked || showBlockedGuidance) && (
|
|
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 space-y-1">
|
|
<p className="text-sm">
|
|
Det här räkenskapsåret är låst, stängt eller har ett bokslut, så dess ingående
|
|
balanser kan inte korrigeras här.
|
|
</p>
|
|
{guidancePeriod ? (
|
|
<p className="text-sm">
|
|
Korrigera i stället ingående balansen för{' '}
|
|
<Link
|
|
href={`/bookkeeping/${guidancePeriod.opening_balance_entry_id}`}
|
|
className="underline underline-offset-2"
|
|
>
|
|
{guidancePeriod.name || guidancePeriod.period_start.slice(0, 4)}
|
|
</Link>
|
|
, det tidigaste öppna året. Då blir saldona rätt framåt. Tidigare, låsta år är
|
|
normalt redan deklarerade sedan tidigare; behöver ett sådant år ändå rättas
|
|
måste det först låsas upp.
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
För att korrigera behöver året först låsas upp (eller bokslutet återföras) under
|
|
Bokföring → Räkenskapsår.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<OpeningBalanceRowEditor initialRows={initialRows} onChange={setState} />
|
|
|
|
{/* Cascade opt-out: later years imported from SIE carry their own IB
|
|
verifikat with the old figures; without this they stay wrong. */}
|
|
{laterPeriodsWithIB.length > 0 && (
|
|
<label className="flex items-start gap-3 rounded-lg border border-border px-4 py-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={cascade}
|
|
onCheckedChange={(v) => setCascade(v === true)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="space-y-1">
|
|
<span className="block text-sm">
|
|
Uppdatera även senare räkenskapsår ({laterPeriodsWithIB.length})
|
|
</span>
|
|
<span className="block text-sm text-muted-foreground">
|
|
Samma ändring förs in i senare års ingående balanser så att saldona stämmer
|
|
framåt, utan nya verifikat. År som är låsta eller har bokslut hoppas över.
|
|
Avser rättelsen ett tidigare års resultat (t.ex. konto 2099) kan en omföring
|
|
till balanserat resultat fortfarande behöva bokföras som vanligt.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
|
Avbryt
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={!state?.canSubmit || isSubmitting}>
|
|
{isSubmitting ? 'Sparar...' : 'Korrigera ingående balanser'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|