fix(transactions): derive pre-migration marker cutoff from imported voucher dates, not fiscal_year_end (#2047)

* fix(transactions): derive pre-migration marker cutoff from imported voucher dates, not fiscal_year_end

A SIE file exported mid-year still declares the full fiscal year in #RAR 0,
so sie_imports.fiscal_year_end is a future date for mid-year migrators and
the 'fran perioden fore din migrering' marker fired on every new bank
transaction until New Year. The cutoff now comes from the latest posted
source_type='import' entry_date (excluding the M-series omforingsverifikation,
which is deliberately dated at fiscal year end), so it tracks where the
imported bokforing actually ends and self-corrects on undo/replace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU5QbL3p8xNB3tSZDbyrr3

* fix(transactions): arm pre-migration cutoff on completed SIE import, exclude omforing by description

Skeptic findings on the frozen commit: (1) source_type='import' is accepted
from v1 API clients, so a never-migrated company with an API-labeled backfill
would get a false cutoff; the marker is now armed only when a completed
sie_imports row exists. (2) Imported vouchers keep the source file's voucher
series, so excluding series M dropped genuine M-series vouchers (6 prod
companies); the omforingsverifikation is now excluded by its hardcoded
description prefix instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU5QbL3p8xNB3tSZDbyrr3

* docs(transactions): state the pre-migration cutoff residuals truthfully

The ledger duplicate guard only reaches the completed-year single-skip case
(7-day window vs a fiscal-year-end-dated aggregate omforing), so it is not
a general backstop for the skip-window gap; the gap is accepted on rarity.
Also documents the rattelse-rename fragility of the description-keyed
exclusion. Skeptic re-review condition, no code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU5QbL3p8xNB3tSZDbyrr3

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-30 23:16:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f43a6653f1
commit 84ba8323b4
4 changed files with 192 additions and 14 deletions
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchMigrationCoverageEnd } from '@/lib/transactions/migration-coverage'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Recording chain mock: remembers every chained call so the test can assert
* the exact filters. The generic createMockSupabase proxy swallows arguments.
* Results are dequeued per from() call: first for sie_imports (arm gate),
* second for journal_entries.
*/
function createRecordingSupabase(results: Array<{ data: unknown; error: unknown }>) {
const calls: Array<{ method: string; args: unknown[] }> = []
const queue = [...results]
const buildChain = () => {
const result = queue.shift() ?? { data: null, error: null }
const chain: Record<string, unknown> = {}
const record =
(method: string) =>
(...args: unknown[]) => {
calls.push({ method, args })
return chain
}
for (const method of ['select', 'eq', 'neq', 'not', 'order', 'limit']) {
chain[method] = record(method)
}
chain.maybeSingle = vi.fn().mockImplementation(() => {
calls.push({ method: 'maybeSingle', args: [] })
return Promise.resolve(result)
})
return chain
}
const from = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: 'from', args })
return buildChain()
})
return { supabase: { from } as unknown as SupabaseClient, calls }
}
const armed = { data: { id: 'import-1' }, error: null }
describe('fetchMigrationCoverageEnd', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the latest imported entry date for a migrated company', async () => {
const { supabase } = createRecordingSupabase([
armed,
{ data: { entry_date: '2026-06-30' }, error: null },
])
await expect(fetchMigrationCoverageEnd(supabase, 'company-1')).resolves.toBe('2026-06-30')
})
it('returns null without a completed SIE import, even if import-typed entries exist', async () => {
// Arm gate: source_type='import' is accepted from API clients, so a
// company that never migrated must never get a cutoff from such entries.
const { supabase, calls } = createRecordingSupabase([
{ data: null, error: null },
{ data: { entry_date: '2026-12-31' }, error: null },
])
await expect(fetchMigrationCoverageEnd(supabase, 'company-1')).resolves.toBeNull()
expect(calls.filter((c) => c.method === 'from').map((c) => c.args[0])).toEqual(['sie_imports'])
})
it('returns null when a migrated company has no imported entries', async () => {
const { supabase } = createRecordingSupabase([armed, { data: null, error: null }])
await expect(fetchMigrationCoverageEnd(supabase, 'company-1')).resolves.toBeNull()
})
it('arms on completed imports and takes the max over posted import entries excluding the omföringsverifikation', async () => {
const { supabase, calls } = createRecordingSupabase([armed, { data: null, error: null }])
await fetchMigrationCoverageEnd(supabase, 'company-1')
expect(calls.filter((c) => c.method === 'from').map((c) => c.args[0])).toEqual([
'sie_imports',
'journal_entries',
])
expect(calls).toContainEqual({ method: 'eq', args: ['status', 'completed'] })
expect(calls).toContainEqual({ method: 'eq', args: ['company_id', 'company-1'] })
expect(calls).toContainEqual({ method: 'eq', args: ['status', 'posted'] })
expect(calls).toContainEqual({ method: 'eq', args: ['source_type', 'import'] })
// The importer's omföringsverifikation is dated at fiscal year end; without
// this exclusion a mid-year migration with skipped vouchers would get a
// future cutoff again (the bug this module exists to fix). Excluded by its
// hardcoded description prefix, NOT by voucher series: imported vouchers
// keep the source file's series, and real files use series M.
expect(calls).toContainEqual({
method: 'not',
args: ['description', 'like', 'Omföringsverifikation:%'],
})
expect(calls.filter((c) => c.method === 'neq')).toEqual([])
expect(calls).toContainEqual({
method: 'order',
args: ['entry_date', { ascending: false }],
})
expect(calls).toContainEqual({ method: 'limit', args: [1] })
})
it('never reads sie_imports.fiscal_year_end as the cutoff', async () => {
// Regression guard for the original bug: the cutoff value must come from
// actual imported data, never the #RAR-declared fiscal year. sie_imports
// is consulted only as the arm gate (select id).
const { supabase, calls } = createRecordingSupabase([armed, { data: null, error: null }])
await fetchMigrationCoverageEnd(supabase, 'company-1')
const selects = calls.filter((c) => c.method === 'select').map((c) => c.args[0])
expect(selects).toEqual(['id', 'entry_date'])
})
})
+71
View File
@@ -0,0 +1,71 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* End of the company's SIE-migration data coverage: the latest entry_date
* among posted imported verifikat. Drives the quiet "från perioden före din
* migrering" marker on inbox rows.
*
* Derived from journal_entries, NOT from sie_imports.fiscal_year_end: a SIE
* file exported mid-year still declares the full fiscal year in #RAR 0, so
* for a mid-year migrator fiscal_year_end is a FUTURE date and every new
* bank transaction satisfied `date <= cutoff` until New Year (the 2026-08-30
* user report). The imported vouchers themselves end where the old system's
* data ends, which is the boundary the marker is about.
*
* Armed only when a completed sie_imports row exists: source_type='import'
* is accepted from API clients too (CreateJournalEntrySchema), so without
* the gate a third-party backfill labeled 'import' would paint a false
* pre-migration marker across a company that never migrated. With the gate,
* such entries can still stretch a real migrator's cutoff, but a company
* labeling API entries 'import' post-migration is doing exactly what the
* label says.
*
* The importer's omföringsverifikation (skipped-voucher adjustment) is
* excluded by its hardcoded description prefix: it is deliberately dated at
* fiscal year end (sie-import.ts) and would reintroduce the future-date bug
* for any import with skipped vouchers. Excluding by description rather
* than by its 'M' voucher series keeps genuine series-M vouchers from the
* source file in the max (prod has companies whose files use series M for
* ordinary vouchers, e.g. moms). Two accepted residuals: (1) bank movement
* covered only by the omföring (skipped vouchers dated after the last
* cleanly imported one) falls outside the cutoff and is mostly unmitigated:
* the ledger duplicate guard only catches a single skipped movement within
* 7 days of the omföring's fiscal-year-end date, never the mid-year or
* aggregated-skip variants. Accepted because it needs a conjunction of
* rare conditions, against the systematic all-year over-marking it
* replaces. Exact closure needs skipped-voucher dates persisted at import
* (skippedDetails in sie-import.ts has them; candidate follow-up: a
* coverage_end column on sie_imports). (2) The exclusion keys on
* description, which inline rättelse can edit: renaming the omföring
* re-admits its fiscal-year-end date and degrades that one company to the
* pre-fix over-marking, nothing worse.
*
* Undo/replace of an import deletes or recreates these entries, so the
* cutoff self-corrects with no stored state to maintain. Returns null when
* the company has no completed migration: callers render no marker.
*/
export async function fetchMigrationCoverageEnd(
supabase: SupabaseClient,
companyId: string,
): Promise<string | null> {
const { data: completedImport } = await supabase
.from('sie_imports')
.select('id')
.eq('company_id', companyId)
.eq('status', 'completed')
.limit(1)
.maybeSingle()
if (!completedImport) return null
const { data } = await supabase
.from('journal_entries')
.select('entry_date')
.eq('company_id', companyId)
.eq('status', 'posted')
.eq('source_type', 'import')
.not('description', 'like', 'Omföringsverifikation:%')
.order('entry_date', { ascending: false })
.limit(1)
.maybeSingle()
return (data as { entry_date?: string } | null)?.entry_date ?? null
}