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>
This commit is contained in:
Jakob Wennberg
2026-08-13 15:16:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 7ccaab7a08
commit e1f13f870a
15 changed files with 856 additions and 81 deletions
+47 -3
View File
@@ -52,7 +52,7 @@ import type {
import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { BankFileParseResult, BankFileFormatId, BankFileDuplicateInfo, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest'
import type {
ImportWizardStep,
@@ -146,6 +146,37 @@ function BankFileImportWizard() {
// Import result
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
// Advisory duplicate preview: which parsed rows ingest will skip because
// they already exist. Fetched from check-duplicates after every (re-)parse;
// a failed check leaves this null and the wizard proceeds without warning
// (execute-side dedup still skips, and the result step reports the count).
const [duplicateInfo, setDuplicateInfo] = useState<BankFileDuplicateInfo | null>(null)
const checkDuplicates = useCallback(async (transactions: BankFileParseResult['transactions'], format: BankFileFormatId) => {
setDuplicateInfo(null)
if (transactions.length === 0) return
try {
const res = await fetch('/api/import/bank-file/check-duplicates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactions, format }),
})
if (!res.ok) return
const data = await res.json()
const preview = data?.data
if (preview && Array.isArray(preview.duplicate_row_indexes)) {
setDuplicateInfo({
duplicate_row_indexes: preview.duplicate_row_indexes,
duplicate_count: typeof preview.duplicate_count === 'number'
? preview.duplicate_count
: preview.duplicate_row_indexes.length,
})
}
} catch {
// Advisory only: never block the wizard on a failed preview.
}
}, [])
// Active PSD2 connections: drives an overlap warning so users don't
// accidentally upload a CSV covering periods we already sync nightly.
const [activePsd2Banks, setActivePsd2Banks] = useState<string[]>([])
@@ -232,8 +263,14 @@ function BankFileImportWizard() {
if (data.data.parse_result.format === 'generic_csv') {
// Auto-detect failed or user picked "Annan CSV": always route to manual column mapping.
// Default mapping rarely matches, so advance regardless of tx count.
// (The duplicate check runs after column mapping instead, on the
// client-side re-parse.)
setDuplicateInfo(null)
setBankStep('column_mapping')
} else if (txCount > 0) {
// Fire-and-forget: the warning card/badges appear when the answer
// lands; the wizard never waits for it.
void checkDuplicates(data.data.parse_result.transactions, data.data.parse_result.format)
setBankStep('preview')
toast({
title: 'Fil analyserad',
@@ -248,15 +285,19 @@ function BankFileImportWizard() {
} finally {
setBankIsLoading(false)
}
}, [toast])
}, [toast, checkDuplicates])
const handleColumnMappingConfirm = useCallback(async (mapping: GenericCSVColumnMapping) => {
// Re-parse with mapping via the generic CSV parser
const { parseGenericCSV } = await import('@/lib/import/bank-file/formats/generic-csv')
const result = parseGenericCSV(rawFileContent, mapping)
setParseResult(result)
// The generic path never re-hits the parse route, so this is its only
// duplicate check. Fire-and-forget: the confirm step renders immediately
// and the warning card appears when the answer lands.
void checkDuplicates(result.transactions, 'generic_csv')
setBankStep('confirm')
}, [rawFileContent])
}, [rawFileContent, checkDuplicates])
const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => {
if (!parseResult) return
@@ -312,6 +353,7 @@ function BankFileImportWizard() {
setBankError(null)
setBankErrorTitle(null)
setRawFileContent('')
setDuplicateInfo(null)
}
return (
@@ -375,6 +417,7 @@ function BankFileImportWizard() {
{bankStep === 'preview' && parseResult && (
<BankFilePreviewStep
parseResult={parseResult}
duplicateInfo={duplicateInfo}
onContinue={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
@@ -397,6 +440,7 @@ function BankFileImportWizard() {
{bankStep === 'confirm' && parseResult && (
<BankFileConfirmStep
parseResult={parseResult}
duplicateInfo={duplicateInfo}
onExecute={handleExecuteImport}
onBack={() => {
if (parseResult.format === 'generic_csv') {
@@ -0,0 +1,125 @@
/**
* Tests for POST /api/import/bank-file/check-duplicates: the advisory
* duplicate preview the bank-file wizard calls after every (re-)parse.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import type { DuplicatePreviewResult } from '@/lib/transactions/dedup-preview'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const TX = {
date: '2024-06-15',
description: 'ICA Maxi Solna',
amount: -250.0,
currency: 'SEK',
}
function makeRequest(body: unknown) {
return createMockRequest('/api/import/bank-file/check-duplicates', {
method: 'POST',
body,
})
}
describe('POST /api/import/bank-file/check-duplicates', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when unauthenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeRequest({ transactions: [TX], format: 'lunar' }))
expect(res.status).toBe(401)
})
it('returns 400 when transactions or format is missing', async () => {
const noFormat = await POST(makeRequest({ transactions: [TX] }))
expect(noFormat.status).toBe(400)
const noTransactions = await POST(makeRequest({ format: 'lunar' }))
expect(noTransactions.status).toBe(400)
const emptyTransactions = await POST(makeRequest({ transactions: [], format: 'lunar' }))
expect(emptyTransactions.status).toBe(400)
})
it('returns 400 for an unknown format id', async () => {
const res = await POST(makeRequest({ transactions: [TX], format: 'not_a_bank' }))
expect(res.status).toBe(400)
})
it('returns 400 when the transactions array exceeds the 20000-row cap', async () => {
const oversized = Array.from({ length: 20001 }, () => TX)
const res = await POST(makeRequest({ transactions: oversized, format: 'lunar' }))
expect(res.status).toBe(400)
})
it('flags rows whose server-computed external_id already exists (id parity with execute)', async () => {
// The stored id is computed with the SAME derivation the execute route
// uses: generateExternalId(tx, format, index). The route only collides on
// it if its own server-side computation is byte-identical.
const storedId = generateExternalId(TX, 'lunar', 0)
enqueue({ data: [] }) // booked map
enqueue({ data: [] }) // unbooked map
enqueue({ data: [{ external_id: storedId }] }) // external_id chunk
const res = await POST(makeRequest({
transactions: [TX, { ...TX, description: 'Coop Stockholm', amount: -99.5 }],
format: 'lunar',
}))
const { status, body } = await parseJsonResponse<{ data: DuplicatePreviewResult }>(res)
expect(status).toBe(200)
expect(body.data.duplicate_row_indexes).toEqual([0])
expect(body.data.duplicate_count).toBe(1)
expect(body.data.by_reason).toEqual({ external_id: 1, content_bridge: 0 })
})
it('flags content-bridge duplicates against stored rows (happy path)', async () => {
enqueue({ data: [] }) // booked map
enqueue({
data: [{
date: TX.date, amount: TX.amount,
original_description: TX.description, description: TX.description,
import_source: 'enable_banking', currency: 'SEK',
}],
}) // unbooked map: PSD2 twin of row 0
enqueue({ data: [] }) // external_id chunk: no Layer-1 hit
const res = await POST(makeRequest({
transactions: [TX, { ...TX, description: 'Coop Stockholm' }],
format: 'lunar',
}))
const { status, body } = await parseJsonResponse<{ data: DuplicatePreviewResult }>(res)
expect(status).toBe(200)
expect(body.data.duplicate_row_indexes).toEqual([0])
expect(body.data.by_reason).toEqual({ external_id: 0, content_bridge: 1 })
})
})
@@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { BankFileCheckDuplicatesSchema } from '@/lib/api/schemas'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import { previewDuplicates } from '@/lib/transactions/dedup-preview'
import type { ParsedBankTransaction } from '@/lib/import/bank-file/types'
/**
* POST /api/import/bank-file/check-duplicates
*
* Read-only duplicate preview for the bank-file import wizard: given the
* parsed rows and their format, reports which rows execute-side ingest will
* skip as duplicates (exact external_id collisions + the content bridge), so
* the wizard can warn BEFORE the user confirms instead of silently importing
* fewer rows than promised.
*
* A dedicated endpoint rather than part of /parse because the generic_csv
* path re-parses client-side after column mapping and never re-hits the parse
* route. The result is ADVISORY: execute's own ingest result stays
* authoritative (see lib/transactions/dedup-preview.ts for the deliberate
* preview/execute differences), and nothing is written here.
*/
export const POST = withRouteContext(
'bank_file.check_duplicates',
async (request, ctx) => {
const { supabase, companyId, log } = ctx
const validation = await validateBody(request, BankFileCheckDuplicatesSchema, {
log,
operation: 'bank_file.check_duplicates',
})
if (!validation.success) return validation.response
const { transactions, format } = validation.data
// Identical id derivation to the execute route: generateExternalId over
// the same rows in the same order, so preview Layer-1 collides on exactly
// the ids execute will collide on.
const raws = transactions.map((tx, index) => ({
date: tx.date,
description: tx.description,
amount: tx.amount,
currency: tx.currency || 'SEK',
external_id: generateExternalId(
{
date: tx.date,
description: tx.description,
amount: tx.amount,
currency: tx.currency || 'SEK',
raw_line: tx.raw_line ?? undefined,
} satisfies ParsedBankTransaction,
format,
index,
),
}))
const preview = await previewDuplicates(supabase, companyId, raws)
return NextResponse.json({ data: preview })
},
)
+6 -13
View File
@@ -63,18 +63,12 @@ export const POST = withRouteContext(
const parseResult = parseBankFile(content, file.name, formatOverride || undefined)
let existingCount = 0
if (parseResult.transactions.length > 0) {
const { count } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.gte('date', parseResult.date_from || '1970-01-01')
.lte('date', parseResult.date_to || '2099-12-31')
existingCount = count || 0
}
// Per-row duplicate detection deliberately does NOT live here: the
// wizard calls POST /api/import/bank-file/check-duplicates after every
// (re-)parse, including the client-side generic_csv re-parse that never
// hits this route. The old existing_transaction_count field (a raw count
// of ALL transactions in the date range) was consumed by nothing and has
// been removed.
return NextResponse.json({
data: {
parse_result: parseResult,
@@ -82,7 +76,6 @@ export const POST = withRouteContext(
detected_format_name: detectedFormat?.name || parseResult.format_name,
file_hash: fileHash,
filename: file.name,
existing_transaction_count: existingCount,
headers: parseResult.format === 'generic_csv'
? content.split('\n')[0]?.split(',').map((h) => h.trim()) || []
: null,
+28 -3
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -19,7 +20,7 @@ import { formatCurrency } from '@/lib/utils'
import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
import type { BankFileParseResult, BankFileDuplicateInfo } from '@/lib/import/bank-file/types'
interface BankAccount {
account_number: string
@@ -28,6 +29,7 @@ interface BankAccount {
interface BankFileConfirmStepProps {
parseResult: BankFileParseResult
duplicateInfo?: BankFileDuplicateInfo | null
onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => void
onBack: () => void
isLoading: boolean
@@ -35,16 +37,22 @@ interface BankFileConfirmStepProps {
export default function BankFileConfirmStep({
parseResult,
duplicateInfo,
onExecute,
onBack,
isLoading,
}: BankFileConfirmStepProps) {
const t = useTranslations('transactions')
const { transactions, stats, date_from, date_to, issues } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
const refsCount = transactions.filter((tx) => tx.reference).length
const warnings = issues.filter((i) => i.severity === 'warning')
// Same per-currency grouping as the preview step: parser-level totals sum
// across currencies, which misleads on Wise/camt.053 multi-currency files.
const currencyTotals = summarizeByCurrency(transactions)
// Advisory: clamp so a stale preview can never produce a negative CTA
// count. Execute stays authoritative; the copy says rows are skipped
// automatically rather than promising an exact final number.
const duplicateCount = Math.min(Math.max(duplicateInfo?.duplicate_count ?? 0, 0), stats.parsed_rows)
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
const [selectedAccount, setSelectedAccount] = useState('1930')
@@ -188,6 +196,23 @@ export default function BankFileConfirmStep({
</CardContent>
</Card>
{/* Duplicate rows: repeated here because the generic_csv path skips the
preview step where the same card is shown. Advisory: ingest skips
them automatically at execute. */}
{duplicateCount > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-warning">
<AlertTriangle className="h-4 w-4" />
{t('import_duplicate_rows_title', { count: duplicateCount })}
</CardTitle>
<CardDescription>
{t('import_duplicate_rows_body')}
</CardDescription>
</CardHeader>
</Card>
)}
{/* Skipped rows: surfaced here because the manual-mapping path skips the
preview step where these warnings would otherwise be shown. */}
{warnings.length > 0 && (
@@ -241,7 +266,7 @@ export default function BankFileConfirmStep({
) : (
<>
<Play className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows} transaktioner
Importera {stats.parsed_rows - duplicateCount} transaktioner
</>
)}
</Button>
+35 -2
View File
@@ -1,7 +1,9 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
@@ -21,19 +23,22 @@ import {
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary'
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
import type { BankFileParseResult, BankFileDuplicateInfo } from '@/lib/import/bank-file/types'
interface BankFilePreviewStepProps {
parseResult: BankFileParseResult
duplicateInfo?: BankFileDuplicateInfo | null
onContinue: () => void
onBack: () => void
}
export default function BankFilePreviewStep({
parseResult,
duplicateInfo,
onContinue,
onBack,
}: BankFilePreviewStepProps) {
const t = useTranslations('transactions')
const { transactions, stats, issues, date_from, date_to } = parseResult
const errors = issues.filter((i) => i.severity === 'error')
const hasIssues = errors.length > 0
@@ -41,6 +46,10 @@ export default function BankFilePreviewStep({
// Wise/camt.053 files can mix currencies per row: the parser-level totals
// sum across currencies, so income/expenses are grouped per currency here.
const currencyTotals = summarizeByCurrency(transactions)
const duplicateCount = duplicateInfo?.duplicate_count ?? 0
// Table rows render transactions.slice(0, 50), so the slice index IS the
// original array index the server flagged.
const duplicateRowSet = new Set(duplicateInfo?.duplicate_row_indexes ?? [])
return (
<div className="space-y-6">
@@ -102,6 +111,23 @@ export default function BankFilePreviewStep({
</Card>
</div>
{/* Duplicate rows already in bookkeeping: advisory, ingest skips them */}
{duplicateCount > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-warning">
<AlertTriangle className="h-4 w-4" />
{t('import_duplicate_rows_title', { count: duplicateCount })}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-muted-foreground">
{t('import_duplicate_rows_body')}
</p>
</CardContent>
</Card>
)}
{/* Warnings */}
{warnings.length > 0 && (
<Card>
@@ -156,7 +182,14 @@ export default function BankFilePreviewStep({
{transactions.slice(0, 50).map((tx, i) => (
<TableRow key={i}>
<TableCell className="font-mono text-sm">{tx.date}</TableCell>
<TableCell className="text-sm">{tx.description}</TableCell>
<TableCell className="text-sm">
{tx.description}
{duplicateRowSet.has(i) && (
<Badge variant="secondary" className="ml-2">
{t('import_duplicate_row_badge')}
</Badge>
)}
</TableCell>
<TableCell
className="text-right font-mono text-sm"
>
+10
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
@@ -20,6 +21,7 @@ export default function BankFileResultStep({
result,
onNewImport,
}: BankFileResultStepProps) {
const t = useTranslations('transactions')
const isSuccess = result.imported > 0 || result.duplicates > 0
return (
@@ -45,6 +47,14 @@ export default function BankFileResultStep({
? `${result.imported} transaktioner importerades framgångsrikt.`
: `${result.errors} fel uppstod under importen.`}
</CardDescription>
{/* Close the silent-dedup loop: without this line, skipped rows just
look like they vanished (fewer imported than parsed, no
explanation). */}
{result.duplicates > 0 && (
<CardDescription>
{t('import_duplicate_result', { count: result.duplicates })}
</CardDescription>
)}
</CardHeader>
{!isSuccess && result.first_error && (
<CardContent>
+54
View File
@@ -15,6 +15,7 @@ import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipien
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
// ============================================================
// Shared primitives
@@ -3339,3 +3340,56 @@ export const MileageSalaryPushSchema = z
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
})
// ============================================================
// Bank file import
// ============================================================
/**
* Known bank-file format ids, mirrored from `BankFileFormatId`
* (lib/import/bank-file/types.ts). `satisfies` pins every member to the union
* at compile time; a format id added to the union but not listed here only
* degrades the ADVISORY duplicate preview (400), never the import itself.
*/
const BANK_FILE_FORMAT_IDS = [
'nordea',
'nordea_business',
'seb',
'swedbank',
'handelsbanken',
'lansforsakringar',
'ica_banken',
'skandia',
'lunar',
'northmill',
'wise',
'wise_statement',
'generic_csv',
'camt053',
] as const satisfies readonly BankFileFormatId[]
/**
* POST /api/import/bank-file/check-duplicates
*
* The rows are client-supplied (the generic_csv path never round-trips through
* the parse route), so the array is hard-capped: the parse route caps files at
* 10 MB, and 20000 rows mirrors that ceiling so an oversized payload cannot
* drive the per-chunk dedup queries as a DoS vector. `raw_line` must pass
* through untouched: camt.053/Wise external_ids are derived from it, and the
* preview must compute byte-identical ids to execute.
*/
export const BankFileCheckDuplicatesSchema = z.object({
transactions: z
.array(
z.object({
date: isoDate,
description: z.string().max(1000),
amount: z.number().finite(),
currency: z.string().max(8).optional().nullable(),
raw_line: z.string().max(4000).optional().nullable(),
})
)
.min(1)
.max(20000),
format: z.enum(BANK_FILE_FORMAT_IDS),
})
+11
View File
@@ -34,6 +34,17 @@ export interface BankFileParseResult {
}
}
/**
* Advisory duplicate preview from POST /api/import/bank-file/check-duplicates:
* rows that already exist and that execute-side ingest will skip. Never a
* promise of the exact final count; ingest's own result stays authoritative.
*/
export interface BankFileDuplicateInfo {
/** Indexes into the parsed transactions array, ascending. */
duplicate_row_indexes: number[]
duplicate_count: number
}
/** Issue encountered during parsing */
export interface BankFileParseIssue {
row: number
@@ -0,0 +1,181 @@
/**
* 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()
})
})
+50 -2
View File
@@ -130,8 +130,10 @@ function makeMappingResult(overrides: Record<string, unknown> = {}) {
// Tests
//
// Queue order after batch dedup refactor:
// 1. Booked transaction map query
// 1b. Unbooked bank-synced transaction map query
// 1. Booked transaction map query (paginated via fetchAllRows: one queue entry
// per page; a page under 1000 rows ends the loop, so the common one-entry
// enqueue is unchanged)
// 1b. Unbooked bank-synced transaction map query (same pagination)
// 2. Supplier invoices fetch
// 3. Batch external_id dedup query (returns matching external_ids)
// 4. Per-transaction: insert, updates, etc.
@@ -378,6 +380,52 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2b-page. Pagination regression: buildExistingTransactionMaps paginates
// via fetchAllRows, so a stored twin BEYOND the 1000-row PostgREST page
// still lands in the dedup map. The old un-paginated select silently
// truncated at 1000 rows, and a re-import over a wide date range in an
// active company inserted everything past the cap as duplicates.
// -----------------------------------------------------------------------
it('dedupes against a stored twin served on the second page (>1000 stored rows)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'ICA Maxi Solna',
external_id: 'lunar_csvhash_page2',
import_source: 'csv_lunar',
})
// Booked map page 1: exactly 1000 filler rows in OTHER (date, öre)
// buckets. A full page forces fetchAllRows to request page 2.
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',
}))
enqueue({ data: filler, error: null })
// Booked map page 2: the twin the old un-paginated query dropped.
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, original_description: 'ICA Maxi Solna', description: 'ICA Maxi Solna', import_source: 'csv_lunar' }],
error: null,
})
// Unbooked map
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query: no match
enqueue({ data: [], error: null })
// No insert expected: deduped by the text bridge against the page-2 twin.
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 2b-edit. Edit-safety regression: a user-edited stored title must NOT
// reopen the duplicate-import window. The content bridge keys off the
+145
View File
@@ -0,0 +1,145 @@
/**
* 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
}
+95 -58
View File
@@ -31,7 +31,7 @@ const MIXED_CURRENCIES = Symbol('mixed-currencies')
* `isImportFeed` drive the cross-channel mirror bridge (see
* `consumeBridgingTwin`); `cashAccountId` is the cross-account guard.
*/
type BucketEntry = {
export type BucketEntry = {
/** Row id of the stored transaction; used to persist hand-mirror adoption. */
id: string | null
desc: string
@@ -64,9 +64,9 @@ type BucketEntry = {
* spliced out per deduped incoming row), so two genuinely-distinct
* same-(date,amount) transactions are never collapsed.
*/
type DescBucket = Map<string, BucketEntry[]>
export type DescBucket = Map<string, BucketEntry[]>
interface ExistingTransactionMaps {
export interface ExistingTransactionMaps {
/**
* Booked transactions (any source): consumed by any incoming raw transaction.
* Hand-entered rows (import_source manual/mcp/null, no bank connection) in
@@ -119,10 +119,43 @@ function addToBucket(
else bucket.set(key, [entry])
}
async function buildExistingTransactionMaps(
/**
* Row shape returned by the two map queries below. `amount` may arrive as a
* PostgREST numeric string; `contentBucketKey` normalizes it to öre.
*/
type StoredDedupRow = {
id: string | null
date: string
amount: number | string
original_description: string | null
description: string | null
cash_account_id: string | null
import_source: string | null
bank_connection_id: string | null
currency: string | null
external_id: string | null
}
/**
* Build the (date, öre)-bucketed maps of already-stored transactions in the
* batch's date range: the stored-row universe every content-dedup layer works
* against.
*
* Exported for the read-only duplicate PREVIEW
* (`lib/transactions/dedup-preview.ts`): the preview must see exactly the
* stored rows execute-side ingest will dedup against, so it reuses this
* function instead of a lookalike query.
*
* Both queries paginate via `fetchAllRows`: PostgREST silently caps a plain
* select at 1000 rows, so a re-import over a wide date range in an active
* company used to dedup against a TRUNCATED map and insert the remainder as
* duplicates. `.order('id')` supplies the stable total order `.range()`
* paging requires (see fetch-all.ts).
*/
export async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
rawTransactions: RawTransaction[]
rawTransactions: Array<{ date: string }>
): Promise<ExistingTransactionMaps> {
const booked: DescBucket = new Map()
const unbookedImported: DescBucket = new Map()
@@ -133,33 +166,35 @@ async function buildExistingTransactionMaps(
const dateTo = dates[dates.length - 1]
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
const bookedRows = await fetchAllRows<StoredDedupRow>(({ from, to }) =>
supabase
.from('transactions')
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
.order('id')
.range(from, to)
)
if (bookedRows) {
for (const tx of bookedRows) {
// Key off the immutable bank original, not the user-editable
// description: a title edit must never make the dedup bridge miss a
// genuine re-import. Falls back to description for rows predating the
// original_description column.
addToBucket(
booked,
tx.id ?? null,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
tx.import_source ?? null,
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
tx.currency ?? null,
tx.external_id ?? null,
)
}
for (const tx of bookedRows) {
// Key off the immutable bank original, not the user-editable
// description: a title edit must never make the dedup bridge miss a
// genuine re-import. Falls back to description for rows predating the
// original_description column.
addToBucket(
booked,
tx.id ?? null,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
tx.import_source ?? null,
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
tx.currency ?? null,
tx.external_id ?? null,
)
}
} catch {
// Non-critical: content-based dedup will be skipped
@@ -171,34 +206,36 @@ async function buildExistingTransactionMaps(
// PSD2 row must dedup an incoming CSV import. Feeds always set a non-null
// import_source outside the user-created allowlist (manual/mcp); null /
// manual / mcp are hand-entered and intentionally excluded.
const { data: unbookedRows } = await supabase
.from('transactions')
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.not('import_source', 'is', null)
.neq('import_source', 'manual')
.neq('import_source', 'mcp')
.gte('date', dateFrom)
.lte('date', dateTo)
const unbookedRows = await fetchAllRows<StoredDedupRow>(({ from, to }) =>
supabase
.from('transactions')
.select('id, date, amount, original_description, description, cash_account_id, import_source, bank_connection_id, currency, external_id')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.not('import_source', 'is', null)
.neq('import_source', 'manual')
.neq('import_source', 'mcp')
.gte('date', dateFrom)
.lte('date', dateTo)
.order('id')
.range(from, to)
)
if (unbookedRows) {
for (const tx of unbookedRows) {
// See booked-map note: dedup on the immutable bank original so a
// user title edit cannot reopen the duplicate-import window.
addToBucket(
unbookedImported,
tx.id ?? null,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
tx.import_source ?? null,
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
tx.currency ?? null,
tx.external_id ?? null,
)
}
for (const tx of unbookedRows) {
// See booked-map note: dedup on the immutable bank original so a
// user title edit cannot reopen the duplicate-import window.
addToBucket(
unbookedImported,
tx.id ?? null,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
tx.import_source ?? null,
isImportedTransaction({ import_source: tx.import_source, bank_connection_id: tx.bank_connection_id }),
tx.currency ?? null,
tx.external_id ?? null,
)
}
} catch {
// Non-critical: reconnect dedup will be skipped
+4
View File
@@ -5345,6 +5345,10 @@
"bank_sync_session_expired_desc": "Reconnect to keep syncing transactions.",
"import_psd2_active_warning_title": "PSD2 is active for {bankName}",
"import_psd2_active_warning_body": "Transactions sync automatically each night. File imports are only needed for older history or when PSD2 isn't working; otherwise duplicates may occur.",
"import_duplicate_rows_title": "{count} rows already exist",
"import_duplicate_rows_body": "These rows already exist in your bookkeeping and are skipped automatically during import. Nothing is imported twice.",
"import_duplicate_row_badge": "Already exists",
"import_duplicate_result": "{count} rows were skipped because they already exist.",
"create_import_desc": "CSV or SIE file from your bank",
"create_manual_desc": "Cash, personal outlays",
"create_synka": "Sync bank now",
+4
View File
@@ -5345,6 +5345,10 @@
"bank_sync_session_expired_desc": "Förnya anslutningen för att fortsätta synka transaktioner.",
"import_psd2_active_warning_title": "PSD2 är aktivt för {bankName}",
"import_psd2_active_warning_body": "Transaktioner synkas automatiskt varje natt. Filimport behövs bara för äldre historik eller om PSD2 inte fungerar; annars kan dubbletter uppstå.",
"import_duplicate_rows_title": "{count} rader finns redan",
"import_duplicate_rows_body": "Raderna finns redan i bokföringen och hoppas över automatiskt vid importen. Inget importeras dubbelt.",
"import_duplicate_row_badge": "Finns redan",
"import_duplicate_result": "{count} rader hoppades över eftersom de redan finns.",
"create_import_desc": "CSV- eller SIE-fil från banken",
"create_manual_desc": "Kontanter, egna utlägg",
"create_synka": "Synka bank nu",