fix(banking): base sync date suggestions on booked coverage and the real fiscal period (#956)

* fix(enable-banking): base sync date suggestions on booked coverage and actual fiscal period

The "start after your bookkeeping" suggestion in the account picker now
uses the latest posted verifikat date (journal_entries, status posted)
instead of sie_imports.fiscal_year_end: the fiscal period end can lie
months past the last actually booked transaction, so the old suggestion
made users skip every unbooked transaction in between. Companies with
no posted entries get no suggestion instead of a misleading one.

The suggested start date (day after the last posted verifikat) is
clamped to today (UTC): the PATCH handler rejects non-past
initial_lookback_from_date values, so a company whose latest verifikat
is dated today would otherwise be suggested tomorrow and get a 400 when
saving.

"Sedan raekenskapsaarets boerjan" now resolves from the fiscal_periods
row containing today, falling back to the recurring
fiscal_year_start_month setting only when no period row exists, so an
extended or shortened first fiscal year (e.g. 2025-10-01 to 2026-12-31)
resolves to its real start date instead of the recurring-year date.

Date logic extracted to lib/date-suggestions.ts with regression tests
covering both issue scenarios.

Fixes #917

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(banking): keep the fiscal-year date masked when the settings fetch fails (CodeRabbit)

A failed company_settings or fiscal_periods query silently fell back to
the calendar-year default, the exact misleading suggestion issue #917
removes. On error the date now stays masked and the request-side
fallback remains the recurring-setting derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-09 21:20:24 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2c5e1ce317
commit 11400b4dec
4 changed files with 201 additions and 33 deletions
+1
View File
@@ -50,3 +50,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-08] One reconciliation PR adopts 3 prod-orphaned migrations (20260707113729 enrichment + 20260708120000/130000 ledger-stats RPCs) plus their pg-tests/fixtures onto main, instead of waiting on #927+#935 to merge: prod ledger was 3 versions ahead of the repo, leaving the default Supabase branch MIGRATIONS_FAILED and blocking every preview branch from being created. SQL committed byte-identical under the exact apply-time versions -> no-op on prod (idempotent), clean on fresh replays, and a no-op on #927/#935's next rebase. Carries #935's DB layer only (migrations + pg-tests + fixtures), not its UI/lib/i18n. Root anti-pattern: all three applied to prod via MCP apply_migration without committing the file (CLAUDE.md "never leave the remote DB ahead of the repo").
[2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock.
[2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible.
[2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against.
@@ -25,10 +25,13 @@ import { Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
getCurrentFiscalYearStart,
getPreviousFiscalYearStart,
daysBetween,
} from '@/lib/company/fiscal-year'
import {
resolveBookedCoverage,
resolveFiscalYearStart,
} from '../lib/date-suggestions'
import type { CompanySettings } from '@/types'
import type { StoredAccount } from '../types'
import {
@@ -79,8 +82,8 @@ export function AccountPickerDialog({
}: AccountPickerDialogProps) {
const { toast } = useToast()
// Memoise so the client has a stable reference across re-renders. Without this,
// listing `supabase` in the SIE-fetch effect's deps would re-fire that query on
// every checkbox tick or parent re-render.
// listing `supabase` in the data-fetch effects' deps would re-fire those queries
// on every checkbox tick or parent re-render.
const supabase = useMemo(() => createClient(), [])
const { company } = useCompany()
@@ -90,11 +93,12 @@ export function AccountPickerDialog({
// the dialog: a rejected save persisted nothing and started no sync, so the
// user must see why and be able to correct the picks.
const [saveError, setSaveError] = useState<string | null>(null)
const [sieLastDate, setSieLastDate] = useState<string | null>(null)
const [lastBookedDate, setLastBookedDate] = useState<string | null>(null)
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
const [chartError, setChartError] = useState(false)
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
const [companySettings, setCompanySettings] = useState<Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null>(null)
const [currentPeriodStart, setCurrentPeriodStart] = useState<string | null>(null)
const [settingsLoaded, setSettingsLoaded] = useState(false)
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
@@ -143,43 +147,68 @@ export function AccountPickerDialog({
}, [open, accounts])
// Load fiscal_year_start_month + entity_type so "Sedan räkenskapsårets början"
// resolves to the right date for non-calendar fiscal years.
// resolves to the right date for non-calendar fiscal years, plus the actual
// fiscal_periods row containing today: the recurring setting cannot represent
// an extended or shortened first year, so the period row wins when it exists.
useEffect(() => {
if (!open || !company?.id) return
let cancelled = false
;(async () => {
const { data } = await supabase
.from('company_settings')
.select('fiscal_year_start_month, entity_type')
.eq('company_id', company.id)
.maybeSingle()
const today = new Date().toISOString().split('T')[0]
const [settingsRes, periodRes] = await Promise.all([
supabase
.from('company_settings')
.select('fiscal_year_start_month, entity_type')
.eq('company_id', company.id)
.maybeSingle(),
supabase
.from('fiscal_periods')
.select('period_start')
.eq('company_id', company.id)
.lte('period_start', today)
.gte('period_end', today)
.order('period_start', { ascending: false })
.limit(1)
.maybeSingle(),
])
if (cancelled) return
setCompanySettings((data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null)
if (settingsRes.error || periodRes.error) {
// A failed fetch must not present the calendar-year fallback as the
// authoritative fiscal-year start (issue #917). Leave settingsLoaded
// false so the date stays masked; if the user proceeds anyway the
// request falls back to the recurring-setting derivation.
return
}
setCompanySettings((settingsRes.data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null)
setCurrentPeriodStart((periodRes.data as { period_start?: string } | null)?.period_start || null)
setSettingsLoaded(true)
})()
return () => { cancelled = true }
}, [open, company?.id, supabase])
// Fetch the latest SIE import end date so we can offer "day after last SIE entry"
// as a one-click escape from the default fiscal-year start. Only matters on the
// initial activation flow: selection edits don't re-run sync.
// Fetch the latest posted verifikat date so we can offer "day after the last
// booked entry" as a one-click escape from the default fiscal-year start.
// Deliberately NOT sie_imports.fiscal_year_end (issue #917): that is the
// fiscal period's end, which can lie months past the last actually booked
// transaction and would make the user skip everything unbooked in between.
// Only matters on the initial activation flow: selection edits don't re-run sync.
useEffect(() => {
if (!open || !isInitialSelection || !company?.id) {
setSieLastDate(null)
setLastBookedDate(null)
return
}
let cancelled = false
;(async () => {
const { data } = await supabase
.from('sie_imports')
.select('fiscal_year_end')
.from('journal_entries')
.select('entry_date')
.eq('company_id', company.id)
.eq('status', 'completed')
.order('fiscal_year_end', { ascending: false })
.eq('status', 'posted')
.order('entry_date', { ascending: false })
.limit(1)
.maybeSingle()
if (cancelled) return
setSieLastDate((data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null)
setLastBookedDate((data as { entry_date?: string } | null)?.entry_date || null)
})()
return () => { cancelled = true }
}, [open, isInitialSelection, company?.id, supabase])
@@ -384,16 +413,14 @@ export function AccountPickerDialog({
}
}
const dayAfterSie = useMemo(() => {
if (!sieLastDate) return null
const d = new Date(sieLastDate)
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
}, [sieLastDate])
const bookedCoverage = useMemo(
() => resolveBookedCoverage(lastBookedDate),
[lastBookedDate],
)
const fiscalYearStart = useMemo(
() => getCurrentFiscalYearStart(companySettings),
[companySettings],
() => resolveFiscalYearStart(currentPeriodStart, companySettings),
[currentPeriodStart, companySettings],
)
const previousFiscalYearStart = useMemo(
@@ -468,13 +495,13 @@ export function AccountPickerDialog({
</p>
</div>
{sieLastDate && dayAfterSie && (
{bookedCoverage && (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-background/60 p-3">
<p className="text-xs text-muted-foreground">
Senaste SIE-importen täcker till{' '}
<span className="font-medium tabular-nums text-foreground">{sieLastDate}</span>.
Ditt senaste bokförda verifikat är daterat{' '}
<span className="font-medium tabular-nums text-foreground">{bookedCoverage.lastBookedDate}</span>.
Vi föreslår{' '}
<span className="font-medium tabular-nums text-foreground">{dayAfterSie}</span>{' '}
<span className="font-medium tabular-nums text-foreground">{bookedCoverage.suggestedStartDate}</span>{' '}
som startdatum så inget överlappar din bokföring.
</p>
<button
@@ -483,7 +510,7 @@ export function AccountPickerDialog({
onClick={() => {
setLookbackMode('custom')
setCustomSubMode('date')
setCustomDate(dayAfterSie)
setCustomDate(bookedCoverage.suggestedStartDate)
}}
disabled={isSaving}
>
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import { resolveBookedCoverage, resolveFiscalYearStart } from '../date-suggestions'
describe('resolveBookedCoverage', () => {
it('suggests the day after the last posted verifikat date', () => {
expect(resolveBookedCoverage('2026-05-14')).toEqual({
lastBookedDate: '2026-05-14',
suggestedStartDate: '2026-05-15',
})
})
it('rolls over month and year boundaries', () => {
// Pin "today" past every case so the clamp does not kick in.
const today = new Date('2030-01-01T12:00:00Z')
expect(resolveBookedCoverage('2026-01-31', today)?.suggestedStartDate).toBe('2026-02-01')
expect(resolveBookedCoverage('2026-12-31', today)?.suggestedStartDate).toBe('2027-01-01')
// Leap year: 2028-02-28 is not the last day of February.
expect(resolveBookedCoverage('2028-02-28', today)?.suggestedStartDate).toBe('2028-02-29')
})
it('clamps to today when the last posted verifikat is dated today (backend rejects non-past dates)', () => {
// Day after 2026-07-09 would be 2026-07-10 (tomorrow), which the PATCH
// handler rejects with 400; the suggestion must stay clickable.
const today = new Date('2026-07-09T12:00:00Z')
expect(resolveBookedCoverage('2026-07-09', today)).toEqual({
lastBookedDate: '2026-07-09',
suggestedStartDate: '2026-07-09',
})
})
it('clamps to today when the last posted verifikat is dated in the future', () => {
const today = new Date('2026-07-09T12:00:00Z')
expect(resolveBookedCoverage('2026-08-15', today)).toEqual({
lastBookedDate: '2026-08-15',
suggestedStartDate: '2026-07-09',
})
})
it('returns null when the company has no posted entries (issue #917: never fall back to fiscal_year_end)', () => {
expect(resolveBookedCoverage(null)).toBeNull()
expect(resolveBookedCoverage(undefined)).toBeNull()
expect(resolveBookedCoverage('')).toBeNull()
})
})
describe('resolveFiscalYearStart', () => {
const calendarYearSettings = {
fiscal_year_start_month: 1,
entity_type: 'aktiebolag' as const,
}
it('prefers the actual fiscal period row over the recurring start month (issue #917: extended first year)', () => {
// Company with an extended first fiscal year 2025-10-01 to 2026-12-31 that
// later runs calendar years: the recurring setting would wrongly resolve
// to 2026-01-01.
expect(
resolveFiscalYearStart('2025-10-01', calendarYearSettings, new Date('2026-07-09')),
).toBe('2025-10-01')
})
it('falls back to the recurring fiscal_year_start_month when no period row exists', () => {
expect(
resolveFiscalYearStart(null, calendarYearSettings, new Date('2026-07-09')),
).toBe('2026-01-01')
expect(
resolveFiscalYearStart(
undefined,
{ fiscal_year_start_month: 7, entity_type: 'aktiebolag' },
new Date('2026-05-01'),
),
).toBe('2025-07-01')
})
it('falls back to calendar year when settings are missing too', () => {
expect(resolveFiscalYearStart(null, null, new Date('2026-07-09'))).toBe('2026-01-01')
})
})
@@ -0,0 +1,63 @@
import type { CompanySettings } from '@/types'
import { getCurrentFiscalYearStart } from '@/lib/company/fiscal-year'
export interface BookedCoverage {
/** Entry date of the company's latest posted verifikat. */
lastBookedDate: string
/**
* Day after lastBookedDate (the earliest sync start that cannot overlap
* booked entries), clamped to today (UTC) so the backend accepts it.
*/
suggestedStartDate: string
}
/**
* Turn the latest posted verifikat date into a "start syncing from" suggestion.
*
* Issue #917: this used to be derived from sie_imports.fiscal_year_end, which
* is the fiscal PERIOD end, not how far the bookkeeping actually reaches. For
* a company whose SIE covered an extended first year (2025-10-01 to 2026-12-31)
* but whose entries stopped in May, the old value suggested a start date past
* every unbooked transaction. Returns null when there is nothing booked: no
* suggestion beats a misleading one.
*/
export function resolveBookedCoverage(
lastPostedEntryDate: string | null | undefined,
today: Date = new Date(),
): BookedCoverage | null {
if (!lastPostedEntryDate) return null
// Pin the math to UTC so the day-after arithmetic is timezone-independent.
const d = new Date(lastPostedEntryDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + 1)
const dayAfter = d.toISOString().split('T')[0]
// The backend PATCH handler (index.ts) rejects initial_lookback_from_date
// unless Date.now() is past the date's UTC midnight, so the newest date it
// accepts is the current UTC date. A company whose latest verifikat is
// dated today (plausible at initial bank activation) would otherwise get
// tomorrow suggested here and a 400 when saving. Clamp to today; ISO date
// strings compare correctly as plain strings.
const todayUtc = today.toISOString().split('T')[0]
return {
lastBookedDate: lastPostedEntryDate,
suggestedStartDate: dayAfter <= todayUtc ? dayAfter : todayUtc,
}
}
/**
* Resolve the start of the current fiscal year, preferring the actual
* fiscal_periods row that contains today over the recurring
* fiscal_year_start_month setting.
*
* Issue #917: the recurring setting cannot represent an extended or shortened
* first fiscal year (e.g. 2025-10-01 to 2026-12-31 for a company that later
* runs calendar years), so deriving from it alone returned 2026-01-01 where
* the real start was 2025-10-01. The period row is authoritative when it
* exists; the setting remains the fallback for companies without period rows.
*/
export function resolveFiscalYearStart(
currentPeriodStart: string | null | undefined,
settings: Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null | undefined,
today: Date = new Date(),
): string {
return currentPeriodStart || getCurrentFiscalYearStart(settings, today)
}