From e1f13f870abe5523e8800e81662eb846e2423cbd Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 15:16:29 +0200 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/import/page.tsx | 50 ++++- .../check-duplicates/__tests__/route.test.ts | 125 ++++++++++++ .../bank-file/check-duplicates/route.ts | 61 ++++++ app/api/import/bank-file/parse/route.ts | 19 +- components/import/BankFileConfirmStep.tsx | 31 ++- components/import/BankFilePreviewStep.tsx | 37 +++- components/import/BankFileResultStep.tsx | 10 + lib/api/schemas.ts | 54 ++++++ lib/import/bank-file/types.ts | 11 ++ .../__tests__/dedup-preview.test.ts | 181 ++++++++++++++++++ lib/transactions/__tests__/ingest.test.ts | 52 ++++- lib/transactions/dedup-preview.ts | 145 ++++++++++++++ lib/transactions/ingest.ts | 153 +++++++++------ messages/en.json | 4 + messages/sv.json | 4 + 15 files changed, 856 insertions(+), 81 deletions(-) create mode 100644 app/api/import/bank-file/check-duplicates/__tests__/route.test.ts create mode 100644 app/api/import/bank-file/check-duplicates/route.ts create mode 100644 lib/transactions/__tests__/dedup-preview.test.ts create mode 100644 lib/transactions/dedup-preview.ts diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 5a512b95..3c271137 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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(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(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([]) @@ -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 && ( { if (parseResult.format === 'generic_csv') { setBankStep('column_mapping') @@ -397,6 +440,7 @@ function BankFileImportWizard() { {bankStep === 'confirm' && parseResult && ( { if (parseResult.format === 'generic_csv') { diff --git a/app/api/import/bank-file/check-duplicates/__tests__/route.test.ts b/app/api/import/bank-file/check-duplicates/__tests__/route.test.ts new file mode 100644 index 00000000..a0f7941f --- /dev/null +++ b/app/api/import/bank-file/check-duplicates/__tests__/route.test.ts @@ -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 }) + }) +}) diff --git a/app/api/import/bank-file/check-duplicates/route.ts b/app/api/import/bank-file/check-duplicates/route.ts new file mode 100644 index 00000000..0d055ad0 --- /dev/null +++ b/app/api/import/bank-file/check-duplicates/route.ts @@ -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 }) + }, +) diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts index 9d8953dc..3363cf3c 100644 --- a/app/api/import/bank-file/parse/route.ts +++ b/app/api/import/bank-file/parse/route.ts @@ -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, diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx index dac925c2..8f887739 100644 --- a/components/import/BankFileConfirmStep.tsx +++ b/components/import/BankFileConfirmStep.tsx @@ -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([]) const [selectedAccount, setSelectedAccount] = useState('1930') @@ -188,6 +196,23 @@ export default function BankFileConfirmStep({ + {/* 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 && ( + + + + + {t('import_duplicate_rows_title', { count: duplicateCount })} + + + {t('import_duplicate_rows_body')} + + + + )} + {/* 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({ ) : ( <> - Importera {stats.parsed_rows} transaktioner + Importera {stats.parsed_rows - duplicateCount} transaktioner )} diff --git a/components/import/BankFilePreviewStep.tsx b/components/import/BankFilePreviewStep.tsx index f9e999c7..cf8efbff 100644 --- a/components/import/BankFilePreviewStep.tsx +++ b/components/import/BankFilePreviewStep.tsx @@ -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 (
@@ -102,6 +111,23 @@ export default function BankFilePreviewStep({
+ {/* Duplicate rows already in bookkeeping: advisory, ingest skips them */} + {duplicateCount > 0 && ( + + + + + {t('import_duplicate_rows_title', { count: duplicateCount })} + + + +

+ {t('import_duplicate_rows_body')} +

+
+
+ )} + {/* Warnings */} {warnings.length > 0 && ( @@ -156,7 +182,14 @@ export default function BankFilePreviewStep({ {transactions.slice(0, 50).map((tx, i) => ( {tx.date} - {tx.description} + + {tx.description} + {duplicateRowSet.has(i) && ( + + {t('import_duplicate_row_badge')} + + )} + diff --git a/components/import/BankFileResultStep.tsx b/components/import/BankFileResultStep.tsx index 22c51428..ea860fe5 100644 --- a/components/import/BankFileResultStep.tsx +++ b/components/import/BankFileResultStep.tsx @@ -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.`} + {/* Close the silent-dedup loop: without this line, skipped rows just + look like they vanished (fewer imported than parsed, no + explanation). */} + {result.duplicates > 0 && ( + + {t('import_duplicate_result', { count: result.duplicates })} + + )} {!isSuccess && result.first_error && ( diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 4d7e73c1..fb5492eb 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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), +}) diff --git a/lib/import/bank-file/types.ts b/lib/import/bank-file/types.ts index f749579b..002e309e 100644 --- a/lib/import/bank-file/types.ts +++ b/lib/import/bank-file/types.ts @@ -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 diff --git a/lib/transactions/__tests__/dedup-preview.test.ts b/lib/transactions/__tests__/dedup-preview.test.ts new file mode 100644 index 00000000..c162e12c --- /dev/null +++ b/lib/transactions/__tests__/dedup-preview.test.ts @@ -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 { + 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() + }) +}) diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index fa2846f3..8703b0ba 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -130,8 +130,10 @@ function makeMappingResult(overrides: Record = {}) { // 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 diff --git a/lib/transactions/dedup-preview.ts b/lib/transactions/dedup-preview.ts new file mode 100644 index 00000000..52db67c4 --- /dev/null +++ b/lib/transactions/dedup-preview.ts @@ -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 { + 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() + 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 +} diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 895fec55..9e99fdb8 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -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 +export type DescBucket = Map -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 { 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(({ 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(({ 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 diff --git a/messages/en.json b/messages/en.json index 2deaeecd..1471726b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 68646f6e..25524b8a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",