e1f13f870a
* 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>
146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
/**
|
|
* Read-only duplicate PREVIEW for the bank-file import wizard.
|
|
*
|
|
* Answers "which of these parsed rows will ingest skip as duplicates?" BEFORE
|
|
* the user confirms the import, reusing the real execute-side machinery:
|
|
*
|
|
* - Layer 1: exact `external_id` collisions, against ids prefetched with the
|
|
* same chunked `.in()` pattern `ingestTransactions` uses.
|
|
* - Layer 2: the content bridge, against the SAME stored-row maps ingest
|
|
* builds (`buildExistingTransactionMaps`), matched with the same
|
|
* (date, öre) bucketing (`contentBucketKey`), the same prefix-containment
|
|
* (`descriptionsBridge`), the same currency guard, and the same COUNTING
|
|
* semantics (each stored twin is consumed once, so two identical incoming
|
|
* rows against one stored row flag exactly one).
|
|
*
|
|
* Deliberate differences from execute (both make the preview ADVISORY, never
|
|
* authoritative; `ingestTransactions`' own result.duplicates remains the
|
|
* truth):
|
|
*
|
|
* - No cross-channel / hand-entered mirrors: they need the batch settlement
|
|
* account and import source, neither of which is known at preview time.
|
|
* Skipping them can only UNDER-count (execute may skip more than previewed),
|
|
* which the UI copy accounts for by never promising an exact final number.
|
|
* - No settlement account means the cash-account guard runs with a null batch
|
|
* account, i.e. it never rejects. A same-(date, öre, description) row on a
|
|
* DIFFERENT bank account of the same company is therefore flagged here even
|
|
* though execute could insert it once both accounts are known. Rare today
|
|
* (CSV rows store cash_account_id NULL for the default 1930) and safe: the
|
|
* warning is advisory.
|
|
* - Nothing is written: no inserts, no hand-mirror adoption stamps.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { buildExistingTransactionMaps, type DescBucket } from '@/lib/transactions/ingest'
|
|
import {
|
|
contentBucketKey,
|
|
descriptionsBridge,
|
|
normalizeImportedDescription,
|
|
} from '@/lib/transactions/external-id'
|
|
|
|
/** Minimal per-row input: what the two dedup layers actually read. */
|
|
export interface PreviewTransaction {
|
|
date: string
|
|
amount: number
|
|
description: string
|
|
/** Must be derived EXACTLY like execute derives it (generateExternalId). */
|
|
external_id: string
|
|
currency?: string | null
|
|
}
|
|
|
|
export interface DuplicatePreviewResult {
|
|
/** Indexes into the input array, ascending. */
|
|
duplicate_row_indexes: number[]
|
|
duplicate_count: number
|
|
by_reason: {
|
|
/** Exact `external_id` collision (Layer 1). */
|
|
external_id: number
|
|
/** Content bridge: same (date, öre), bridging description (Layer 2). */
|
|
content_bridge: number
|
|
}
|
|
}
|
|
|
|
export async function previewDuplicates(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
raws: PreviewTransaction[],
|
|
): Promise<DuplicatePreviewResult> {
|
|
const result: DuplicatePreviewResult = {
|
|
duplicate_row_indexes: [],
|
|
duplicate_count: 0,
|
|
by_reason: { external_id: 0, content_bridge: 0 },
|
|
}
|
|
if (raws.length === 0) return result
|
|
|
|
// The same stored-row universe execute-side ingest dedups against.
|
|
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, raws)
|
|
|
|
// Layer-1 prefetch: chunked to stay clear of URL length limits, same shape
|
|
// as ingestTransactions.
|
|
const existingExternalIds = new Set<string>()
|
|
const externalIds = raws.map((t) => t.external_id)
|
|
for (let i = 0; i < externalIds.length; i += 500) {
|
|
const chunk = externalIds.slice(i, i + 500)
|
|
const { data } = await supabase
|
|
.from('transactions')
|
|
.select('external_id')
|
|
.eq('company_id', companyId)
|
|
.in('external_id', chunk)
|
|
data?.forEach((r) => existingExternalIds.add(r.external_id))
|
|
}
|
|
|
|
const flag = (index: number, reason: 'external_id' | 'content_bridge') => {
|
|
result.duplicate_row_indexes.push(index)
|
|
result.duplicate_count++
|
|
result.by_reason[reason]++
|
|
}
|
|
|
|
for (let index = 0; index < raws.length; index++) {
|
|
const raw = raws[index]
|
|
|
|
// Layer 1: exact id collision, identical to ingest's first check.
|
|
if (existingExternalIds.has(raw.external_id)) {
|
|
flag(index, 'external_id')
|
|
continue
|
|
}
|
|
|
|
// Layer 2: text bridge with counting semantics. Booked map first, then
|
|
// unbooked imports, mirroring ingest's consume order. LONGEST bridging
|
|
// description wins so a more-specific twin is consumed before a generic
|
|
// one, exactly like consumeBridgingTwin in ingest.ts.
|
|
const description = normalizeImportedDescription(raw.description)
|
|
const bucketKey = contentBucketKey(raw.date, raw.amount)
|
|
const rawCurrency = raw.currency ?? null
|
|
const consumeTextBridgeTwin = (bucket: DescBucket): boolean => {
|
|
const entries = bucket.get(bucketKey)
|
|
if (!entries || entries.length === 0) return false
|
|
let bestIdx = -1
|
|
let bestLen = -1
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i]
|
|
// Currency guard, same null-tolerance as ingest: the bucket key
|
|
// carries no currency, so 250,00 SEK and 250,00 EUR on one date share
|
|
// a bucket; a known mismatch means two different movements.
|
|
const sameCurrency =
|
|
entry.currency === null || rawCurrency === null || entry.currency === rawCurrency
|
|
if (!sameCurrency) continue
|
|
if (descriptionsBridge(description, entry.desc) && entry.desc.length > bestLen) {
|
|
bestIdx = i
|
|
bestLen = entry.desc.length
|
|
}
|
|
}
|
|
if (bestIdx === -1) return false
|
|
entries.splice(bestIdx, 1)
|
|
return true
|
|
}
|
|
if (
|
|
consumeTextBridgeTwin(existingMaps.booked) ||
|
|
consumeTextBridgeTwin(existingMaps.unbookedImported)
|
|
) {
|
|
flag(index, 'content_bridge')
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|