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:
co-authored by
Claude Fable 5
parent
f43a6653f1
commit
84ba8323b4
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user