fix(reconciliation): scope bank avstämning to a fiscal period so the IB stops counting (#751) (#754)

* fix(reconciliation): scope bank reconciliation to a fiscal period so the IB stops counting (#751)

The bank reconciliation widget defaulted its date window to "full history"
(empty dateFrom). With no lower bound the GL side spans the fiscal-year
boundary: a prior period's movements on the account net to exactly the
opening balance, and the new period's IB entry adds another copy. The IB
*summary* was excluded but the prior-period *detail* stayed in the period
movement while the bank feed only covered the current period — a phantom
difference equal to the IB (the "räknar med IB fast den säger borträknad"
report in #751).

- Server: getReconciliationStatus now floors the window at the most recent
  opening-balance date on the account (effectiveFrom = max(dateFrom, ibDate))
  and clamps both the GL movement set and the bank-feed set identically.
  Derived from the already-fetched lines — no extra query. A no-op when the
  caller already passes period_start; a safety net otherwise.
- UI: BankReconciliationView scopes to a fiscal period via FiscalYearSelector
  (defaults to the newest period), seeding dateFrom/dateTo and gating the
  initial fetch so the full-history numbers never flash.
- Tests: two regression cases reproducing the cross-period scenario.

Proven against prod: full-history -> difference -10 172,94 (matched the
screenshot); period-bounded -> 0,00.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reconciliation): re-fetch on fiscal-period switch; document IB-floor choice

Review follow-up on #754:
- BankReconciliationView: add selectedPeriodId to the gated fetch effect deps
  so switching räkenskapsår re-fetches with the new window, and a late period
  selection (selector signalling ready before the company context hydrates) still
  triggers the real period-scoped fetch instead of leaving the empty-window
  result. Manual date edits still stay on the explicit "Filtrera" action.
- getReconciliationStatus: comment why ibFloor takes the LATEST opening-balance
  date (one IB per period invariant; across a multi-year window the most recent
  IB is the intended floor; same-date duplicates cancel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reconciliation): cover mid-period window (dateFrom after the IB date)

Review follow-up on #754: documents that a per-month reconciliation window
starting after the fiscal-year IB correctly excludes the IB and reconciles on
the in-window movements alone (gl_1930_opening_balance = 0 by design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-18 13:49:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 241959513b
commit 027734ffc7
3 changed files with 213 additions and 25 deletions
+47 -5
View File
@@ -13,6 +13,7 @@ import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOf
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker'
import {
DropdownMenu,
@@ -28,7 +29,7 @@ import {
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import type { CashAccount } from '@/types'
import type { CashAccount, FiscalPeriod } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -129,11 +130,17 @@ export function BankReconciliationView() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// dateFrom stays empty by default (full history) so nothing the user needs to
// reconcile is hidden on first load. dateTo defaults to today so the field
// isn't a blank "åååå-mm-dd" and the upper bound is concrete.
// The window is scoped to a fiscal period (issue #751): a bank reconciliation
// is inherently per-period, and a "full history" window spans the fiscal-year
// boundary — mixing a prior period's movements with the current year's IB and
// manufacturing a phantom difference equal to the IB. dateFrom/dateTo are
// seeded from the selected räkenskapsår below (and stay editable as a manual
// override). The first fetch is gated on `periodReady` so we never flash the
// full-history numbers before the period is known.
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10))
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(null)
const [periodReady, setPeriodReady] = useState(false)
const [accountNumber, setAccountNumber] = useState('1930')
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
// Date filters apply on demand (the "Filtrera" button or an account switch),
@@ -299,9 +306,37 @@ export function BankReconciliationView() {
// "Filtrera" button calls fetchAll() explicitly for date changes.
}, [accountNumber, accountCurrency, includeMatched])
// Seed the reconciliation window from the selected räkenskapsår. dateTo is
// clamped to today for the current (open) year so we don't claim to reconcile
// into the future; a past year ends at its period_end. periodReady unblocks the
// gated initial fetch below. The dates stay editable via the manual inputs.
const handlePeriodChange = useCallback(
(periodId: string | null, period?: FiscalPeriod | null) => {
setSelectedPeriodId(periodId)
if (period) {
const today = new Date().toISOString().slice(0, 10)
setDateFrom(period.period_start)
setDateTo(period.period_end < today ? period.period_end : today)
}
setPeriodReady(true)
},
[],
)
// Gate the first load until the fiscal period (and therefore the date window)
// is known — otherwise we'd fetch once with the empty full-history window and
// briefly render the phantom-diff numbers before the period seeds the dates.
// The date-seeding setState in handlePeriodChange updates dateFromRef/dateToRef
// (the effects above) before this effect runs on the same render, so fetchAll
// reads the freshly-seeded window. selectedPeriodId is a dependency so that
// switching räkenskapsår re-fetches with the new window, and so a late period
// selection (e.g. if the selector signals ready before the company context has
// hydrated and onChange arrives a tick later) still triggers the real fetch.
// Manual date edits intentionally do NOT auto-fetch — that stays on "Filtrera".
useEffect(() => {
if (!periodReady) return
fetchAll()
}, [fetchAll])
}, [fetchAll, periodReady, selectedPeriodId])
// Reset transient per-account UI state when the selected account changes. A
// verifikation pick or a dry-run preview computed for the previous account is
@@ -644,6 +679,13 @@ export function BankReconciliationView() {
value={accountNumber}
onChange={setAccountNumber}
/>
<FiscalYearSelector
value={selectedPeriodId}
onChange={handlePeriodChange}
onReady={() => setPeriodReady(true)}
includeAllOption={false}
hideFuturePeriods
/>
<div>
<Label>Datum från</Label>
<Input