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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
7ccaab7a08
commit
e1f13f870a
@@ -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 })
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user