Files
accounted/lib/transactions/__tests__/dedup-preview.test.ts
T
e1f13f870a feat(import): warn about already-imported rows in the bank-file wizard (#1567)
* fix(transactions): paginate the ingest dedup maps past the 1000-row cap

buildExistingTransactionMaps issued un-paginated selects for the booked and
unbooked dedup maps, so PostgREST silently truncated each at 1000 rows: a
re-import over a wide date range in an active company deduped against a
partial map and inserted everything past the cap as duplicates. Both queries
now go through fetchAllRows with a stable .order('id') for range paging.

Also exports the function and its types for the upcoming read-only duplicate
preview, which must share the exact stored-row universe execute-side ingest
dedups against.

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

* feat(import): add read-only duplicate preview endpoint for bank files

New POST /api/import/bank-file/check-duplicates (withRouteContext + Zod,
transactions capped at 20000) computes external_ids with the exact
generateExternalId(tx, format, index) derivation execute uses and runs
previewDuplicates: Layer-1 id collisions plus the Layer-2 text bridge with
counting semantics and the currency guard, against the same stored-row maps
ingest builds (buildExistingTransactionMaps). The result is advisory; execute
stays authoritative and mirrors/settlement-account guards are documented
preview/execute differences.

A dedicated endpoint because the generic_csv path re-parses client-side and
never re-hits /parse. Also removes the dead existing_transaction_count field
from the parse response (a raw date-range count consumed by nothing).

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

* feat(import): surface duplicate rows in the bank-file import wizard

Overlapping bank imports used to dedup silently: the wizard promised
'Importera N transaktioner', ingest skipped the twins, and the user saw fewer
rows than parsed with zero explanation. The wizard now calls check-duplicates
after a successful parse AND inside handleColumnMappingConfirm (the
generic_csv path never re-hits parse), and:

- BankFilePreviewStep: warning card in the AlertTriangle pattern ('{count}
  rader finns redan', skipped automatically) plus a 'Finns redan' badge on
  flagged rows in the 50-row table
- BankFileConfirmStep: repeats the summary card (generic path skips preview)
  and the CTA counts 'Importera {parsed - duplicates} transaktioner'
- BankFileResultStep: renders result.duplicates when > 0, closing the loop
  ingest.ts documents as unrendered

Execute semantics unchanged: all rows are sent, ingest skips; the preview is
advisory and never promises an exact final number. New strings in both
messages/sv.json and messages/en.json next to the import_psd2 anchors.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:16:29 +02:00

182 lines
6.8 KiB
TypeScript

/**
* Tests for the read-only bank-import duplicate preview.
*
* previewDuplicates must behave like the corresponding subset of
* ingestTransactions' dedup (Layer-1 external_id + the Layer-2 text bridge
* with counting semantics and the currency guard), because the wizard shows
* its answer as "these rows will be skipped". The queue order per call is:
* booked map page(s), unbooked map page(s), then one external_id chunk query
* per 500 rows.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { previewDuplicates, type PreviewTransaction } from '../dedup-preview'
import { createQueuedMockSupabase } from '@/tests/helpers'
const COMPANY_ID = 'company-1'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
function makePreviewRow(overrides: Partial<PreviewTransaction> = {}): PreviewTransaction {
return {
date: '2024-06-15',
amount: -250.0,
description: 'ICA Maxi Solna',
external_id: `csv_lunar_${Math.random().toString(36).slice(2, 8)}`,
currency: 'SEK',
...overrides,
}
}
describe('previewDuplicates', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
// (a) Layer 1: exact external_id collision.
it('flags an exact external_id hit under by_reason.external_id', async () => {
const raw = makePreviewRow({ external_id: 'csv_lunar_stored' })
enqueue({ data: [] }) // booked map
enqueue({ data: [] }) // unbooked map
enqueue({ data: [{ external_id: 'csv_lunar_stored' }] }) // external_id chunk
const result = await previewDuplicates(supabase as never, COMPANY_ID, [raw])
expect(result.duplicate_row_indexes).toEqual([0])
expect(result.duplicate_count).toBe(1)
expect(result.by_reason).toEqual({ external_id: 1, content_bridge: 0 })
})
// (b) Layer 2: content bridge against a stored unbooked import-feed row
// whose description bridges by prefix containment.
it('flags a content-bridge hit (same date+öre, prefix description) under by_reason.content_bridge', async () => {
const raw = makePreviewRow({ description: 'ICA Maxi Solna Kortköp' })
enqueue({ data: [] }) // booked map
enqueue({
data: [{
date: '2024-06-15', amount: -250.0,
original_description: 'ICA Maxi Solna', description: 'ICA Maxi Solna',
import_source: 'csv_lunar', currency: 'SEK',
}],
}) // unbooked map: stored CSV twin, stored title is a prefix of incoming
enqueue({ data: [] }) // external_id chunk: different id, no Layer-1 hit
const result = await previewDuplicates(supabase as never, COMPANY_ID, [raw])
expect(result.duplicate_row_indexes).toEqual([0])
expect(result.by_reason).toEqual({ external_id: 0, content_bridge: 1 })
})
// (c) Counting semantics: N stored twins flag exactly N incoming rows.
it('flags exactly one of two identical incoming rows against one stored twin', async () => {
const rows = [
makePreviewRow({ external_id: 'csv_lunar_a' }),
makePreviewRow({ external_id: 'csv_lunar_b' }),
]
enqueue({ data: [] }) // booked map
enqueue({
data: [{
date: '2024-06-15', amount: -250.0,
original_description: 'ICA Maxi Solna', description: 'ICA Maxi Solna',
import_source: 'csv_lunar', currency: 'SEK',
}],
}) // unbooked map: ONE stored twin
enqueue({ data: [] }) // external_id chunk
const result = await previewDuplicates(supabase as never, COMPANY_ID, rows)
expect(result.duplicate_row_indexes).toEqual([0])
expect(result.duplicate_count).toBe(1)
expect(result.by_reason.content_bridge).toBe(1)
})
// (d) A same-(date, öre) row with a non-bridging description is NOT flagged
// (mirrors ingest's same-channel different-description semantics).
it('does not flag a same-(date, öre) row whose description does not bridge', async () => {
const raw = makePreviewRow({ description: 'Coop Stockholm' })
enqueue({ data: [] }) // booked map
enqueue({
data: [{
date: '2024-06-15', amount: -250.0,
original_description: 'ICA Maxi Solna', description: 'ICA Maxi Solna',
import_source: 'csv_lunar', currency: 'SEK',
}],
}) // unbooked map
enqueue({ data: [] }) // external_id chunk
const result = await previewDuplicates(supabase as never, COMPANY_ID, [raw])
expect(result.duplicate_row_indexes).toEqual([])
expect(result.duplicate_count).toBe(0)
})
// (e) Currency guard: identical titles and öre in DIFFERENT currencies never
// bridge (a stored 250,00 EUR row is not the incoming 250,00 SEK row).
it('does not flag across currencies (stored EUR vs incoming SEK, same öre and title)', async () => {
const raw = makePreviewRow({ currency: 'SEK' })
enqueue({ data: [] }) // booked map
enqueue({
data: [{
date: '2024-06-15', amount: -250.0,
original_description: 'ICA Maxi Solna', description: 'ICA Maxi Solna',
import_source: 'csv_lunar', currency: 'EUR',
}],
}) // unbooked map: EUR twin in the same (date, öre) bucket
enqueue({ data: [] }) // external_id chunk
const result = await previewDuplicates(supabase as never, COMPANY_ID, [raw])
expect(result.duplicate_row_indexes).toEqual([])
expect(result.duplicate_count).toBe(0)
})
// (f) Pagination: a stored twin beyond the 1000-row PostgREST page must
// still land in the map (the fetchAllRows fix; the old un-paginated query
// silently truncated at 1000 rows and missed it).
it('flags a twin served on the second stored page (>1000 rows)', async () => {
const raw = makePreviewRow()
// Page 1: exactly 1000 filler rows in other (date, öre) buckets, which
// forces fetchAllRows to request a second page.
const filler = Array.from({ length: 1000 }, (_, i) => ({
date: '2024-06-01',
amount: -(i + 1),
original_description: `Filler ${i}`,
description: `Filler ${i}`,
import_source: 'csv_lunar',
currency: 'SEK',
}))
enqueue({ data: filler }) // booked map page 1 (full page)
enqueue({
data: [{
date: '2024-06-15', amount: -250.0,
original_description: 'ICA Maxi Solna', description: 'ICA Maxi Solna',
import_source: 'csv_lunar', currency: 'SEK',
}],
}) // booked map page 2: the twin
enqueue({ data: [] }) // unbooked map
enqueue({ data: [] }) // external_id chunk
const result = await previewDuplicates(supabase as never, COMPANY_ID, [raw])
expect(result.duplicate_row_indexes).toEqual([0])
expect(result.by_reason.content_bridge).toBe(1)
})
it('returns an empty result for an empty batch without querying', async () => {
const result = await previewDuplicates(supabase as never, COMPANY_ID, [])
expect(result).toEqual({
duplicate_row_indexes: [],
duplicate_count: 0,
by_reason: { external_id: 0, content_bridge: 0 },
})
expect(supabase.from).not.toHaveBeenCalled()
})
})