diff --git a/bun.lock b/bun.lock index 32174448..d60e20a7 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "date-fns": "^4.1.0", "globals": "^15.14.0", "input-otp": "^1.4.2", + "json-logic-js": "^2.0.5", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.553.0", "react": "^19.2.0", @@ -49,6 +50,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@types/json-logic-js": "^2.0.8", "@types/node": "^22.13.5", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.17.0", @@ -417,6 +419,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/json-logic-js": ["@types/json-logic-js@2.0.8", "", {}, "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], @@ -777,6 +781,8 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-logic-js": ["json-logic-js@2.0.5", "", {}, "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 94b0a87a..465c5104 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -1,33 +1,36 @@ -import { useState, useEffect } from 'react'; +import AlertError from '@/components/alert-error'; import { Drawer, DrawerContent, + DrawerDescription, DrawerHeader, DrawerTitle, - DrawerDescription, } from '@/components/ui/drawer'; -import { ImportStepAccount } from './import-step-account'; -import { ImportStepUpload } from './import-step-upload'; -import { ImportStepMapping } from './import-step-mapping'; -import { ImportStepPreview } from './import-step-preview'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { useSyncContext } from '@/contexts/sync-context'; import { - ImportStep, - DateFormat, - type ImportState, - type ColumnMapping, -} from '@/types/import'; -import { type Account } from '@/types/account'; -import { - parseFile, autoDetectColumns, convertRowsToTransactions, + parseFile, } from '@/lib/file-parser'; -import { transactionSyncService } from '@/services/transaction-sync'; +import { + loadImportConfig, + saveImportConfig, +} from '@/lib/import-config-storage'; import { accountSyncService } from '@/services/account-sync'; -import { useSyncContext } from '@/contexts/sync-context'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import AlertError from '@/components/alert-error'; -import { saveImportConfig, loadImportConfig } from '@/lib/import-config-storage'; +import { transactionSyncService } from '@/services/transaction-sync'; +import { type Account } from '@/types/account'; +import { + DateFormat, + ImportStep, + type ColumnMapping, + type ImportState, +} from '@/types/import'; +import { useEffect, useState } from 'react'; +import { ImportStepAccount } from './import-step-account'; +import { ImportStepMapping } from './import-step-mapping'; +import { ImportStepPreview } from './import-step-preview'; +import { ImportStepUpload } from './import-step-upload'; interface ImportTransactionsDrawerProps { open: boolean; @@ -42,7 +45,9 @@ export function ImportTransactionsDrawer({ const { isKeySet } = useEncryptionKey(); const [isImporting, setIsImporting] = useState(false); const [error, setError] = useState(null); - const [selectedAccount, setSelectedAccount] = useState(null); + const [selectedAccount, setSelectedAccount] = useState( + null, + ); const [state, setState] = useState({ step: ImportStep.SelectAccount, selectedAccountId: null, @@ -62,11 +67,13 @@ export function ImportTransactionsDrawer({ useEffect(() => { if (state.selectedAccountId) { - accountSyncService.getById(state.selectedAccountId).then((account) => { - if (account) { - setSelectedAccount(account); - } - }); + accountSyncService + .getById(state.selectedAccountId) + .then((account) => { + if (account) { + setSelectedAccount(account); + } + }); } }, [state.selectedAccountId]); @@ -111,16 +118,25 @@ export function ImportTransactionsDrawer({ } try { - const { headers, data, columns, headerRowIndex } = await parseFile(file); + const { headers, data, columns, headerRowIndex } = + await parseFile(file); const autoMapping = autoDetectColumns(headers); const columnOptions = headers.map((header, index) => { const columnData = columns[index] || []; const middleIndex = Math.floor(columnData.length / 2); const examples = columnData - .slice(Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3) - .filter(cell => cell !== null && cell !== undefined && String(cell).trim() !== '') - .map(cell => String(cell)) + .slice( + Math.max(headerRowIndex + 1, middleIndex), + Math.max(headerRowIndex + 1, middleIndex) + 3, + ) + .filter( + (cell) => + cell !== null && + cell !== undefined && + String(cell).trim() !== '', + ) + .map((cell) => String(cell)) .slice(0, 3); return { @@ -133,8 +149,13 @@ export function ImportTransactionsDrawer({ let detectedFormat = DateFormat.YearMonthDay; let formatDetected = false; if (autoMapping.transaction_date) { - const { autoDetectDateFormat } = await import('@/lib/file-parser'); - const detected = autoDetectDateFormat(data, autoMapping.transaction_date); + const { autoDetectDateFormat } = await import( + '@/lib/file-parser' + ); + const detected = autoDetectDateFormat( + data, + autoMapping.transaction_date, + ); if (detected) { detectedFormat = detected; formatDetected = true; @@ -148,9 +169,15 @@ export function ImportTransactionsDrawer({ const savedConfig = loadImportConfig(state.selectedAccountId); if (savedConfig) { - const isValidMapping = (mapping: ColumnMapping): boolean => { - const values = Object.values(mapping).filter(v => v !== null); - return values.every(value => headers.includes(value as string)); + const isValidMapping = ( + mapping: ColumnMapping, + ): boolean => { + const values = Object.values(mapping).filter( + (v) => v !== null, + ); + return values.every((value) => + headers.includes(value as string), + ); }; if (isValidMapping(savedConfig.columnMapping)) { @@ -173,17 +200,12 @@ export function ImportTransactionsDrawer({ })); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Failed to parse file' + err instanceof Error ? err.message : 'Failed to parse file', ); } }; - const handleMappingChange = ( - field: keyof ColumnMapping, - value: string - ) => { + const handleMappingChange = (field: keyof ColumnMapping, value: string) => { setState((prev) => ({ ...prev, columnMapping: { @@ -202,11 +224,11 @@ export function ImportTransactionsDrawer({ const parsedTransactions = convertRowsToTransactions( state.parsedData, state.columnMapping, - state.dateFormat + state.dateFormat, ); const account = await accountSyncService.getById( - state.selectedAccountId! + state.selectedAccountId!, ); if (!account) { @@ -214,20 +236,16 @@ export function ImportTransactionsDrawer({ return; } - const transactionsWithDuplicateCheck = await Promise.all( - parsedTransactions.map(async (transaction) => { - const isDuplicate = await transactionSyncService.isDuplicate( - account.id, - transaction.transaction_date, - transaction.amount, - transaction.description - ); + const duplicateFlags = await transactionSyncService.checkDuplicates( + account.id, + parsedTransactions, + ); - return { - ...transaction, - isDuplicate, - }; - }) + const transactionsWithDuplicateCheck = parsedTransactions.map( + (transaction, index) => ({ + ...transaction, + isDuplicate: duplicateFlags[index], + }), ); if (state.selectedAccountId) { @@ -246,7 +264,7 @@ export function ImportTransactionsDrawer({ setError( err instanceof Error ? err.message - : 'Failed to process transactions' + : 'Failed to process transactions', ); } }; @@ -266,18 +284,20 @@ export function ImportTransactionsDrawer({ } const newTransactions = state.transactions.filter( - (t) => !t.isDuplicate + (t) => !t.isDuplicate, ); const transactionsToImport = await Promise.all( newTransactions.map(async (transaction) => { const { encrypted, iv } = await transactionSyncService.encryptDescription( - transaction.description + transaction.description, ); return { - user_id: (selectedAccount as Account & { user_id?: number }).user_id || 0, + user_id: + (selectedAccount as Account & { user_id?: number }) + .user_id || 0, account_id: selectedAccount.id, category_id: null, description: encrypted, @@ -288,7 +308,7 @@ export function ImportTransactionsDrawer({ notes: null, notes_iv: null, }; - }) + }), ); await transactionSyncService.createMany(transactionsToImport); @@ -300,7 +320,7 @@ export function ImportTransactionsDrawer({ setError( err instanceof Error ? err.message - : 'Failed to import transactions' + : 'Failed to import transactions', ); } finally { setIsImporting(false); @@ -316,17 +336,20 @@ export function ImportTransactionsDrawer({ case ImportStep.SelectAccount: return { title: 'Select Account', - description: 'Choose the account where transactions will be imported', + description: + 'Choose the account where transactions will be imported', }; case ImportStep.UploadFile: return { title: 'Upload File', - description: 'Drop your CSV or Excel file here, or click to browse', + description: + 'Drop your CSV or Excel file here, or click to browse', }; case ImportStep.MapColumns: return { title: 'Map Columns', - description: 'Match your file columns to transaction fields', + description: + 'Match your file columns to transaction fields', }; case ImportStep.Preview: return { @@ -398,7 +421,9 @@ export function ImportTransactionsDrawer({
{stepInfo.title} - {stepInfo.description} + + {stepInfo.description} + {error && (
@@ -411,4 +436,3 @@ export function ImportTransactionsDrawer({ ); } - diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 5d21dbdb..305b51b2 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -1,7 +1,7 @@ -import { SyncManager } from '@/lib/sync-manager'; -import { indexedDBService } from '@/lib/indexeddb'; import { encrypt, importKey } from '@/lib/crypto'; +import { indexedDBService } from '@/lib/indexeddb'; import { getStoredKey } from '@/lib/key-storage'; +import { SyncManager } from '@/lib/sync-manager'; import { uuidv7 } from 'uuidv7'; export interface Transaction { @@ -45,18 +45,28 @@ class TransactionSyncService { async getByAccountId(accountId: number): Promise { try { const allTransactions = await this.getAll(); - return allTransactions.filter(t => t.account_id === accountId); + return allTransactions.filter((t) => t.account_id === accountId); } catch (error) { console.warn('Failed to get transactions from IndexedDB:', error); return []; } } - async create(data: Omit): Promise { - return await this.syncManager.createLocal(data as Omit & { id?: number; created_at?: string; updated_at?: string }); + async create( + data: Omit, + ): Promise { + return await this.syncManager.createLocal( + data as Omit & { + id?: number; + created_at?: string; + updated_at?: string; + }, + ); } - async createMany(transactions: Omit[]): Promise { + async createMany( + transactions: Omit[], + ): Promise { try { const timestamp = new Date().toISOString(); const created: Transaction[] = []; @@ -82,7 +92,9 @@ class TransactionSyncService { return created; } catch (error) { console.error('Failed to create transactions in IndexedDB:', error); - throw new Error('Failed to save transactions locally. Please refresh the page and try again.'); + throw new Error( + 'Failed to save transactions locally. Please refresh the page and try again.', + ); } } @@ -99,7 +111,11 @@ class TransactionSyncService { }; await indexedDBService.put('transactions', updated); - await indexedDBService.addPendingChange('transactions', 'update', updated); + await indexedDBService.addPendingChange( + 'transactions', + 'update', + updated, + ); } async updateMany(ids: string[], data: Partial): Promise { @@ -119,7 +135,11 @@ class TransactionSyncService { }; await indexedDBService.put('transactions', updated); - await indexedDBService.addPendingChange('transactions', 'update', updated); + await indexedDBService.addPendingChange( + 'transactions', + 'update', + updated, + ); } } @@ -130,7 +150,11 @@ class TransactionSyncService { } await indexedDBService.delete('transactions', id); - await indexedDBService.addPendingChange('transactions', 'delete', transaction); + await indexedDBService.addPendingChange( + 'transactions', + 'delete', + transaction, + ); } async deleteMany(ids: string[]): Promise { @@ -142,7 +166,116 @@ class TransactionSyncService { } await indexedDBService.delete('transactions', id); - await indexedDBService.addPendingChange('transactions', 'delete', transaction); + await indexedDBService.addPendingChange( + 'transactions', + 'delete', + transaction, + ); + } + } + + async checkDuplicates( + accountId: number, + transactions: Array<{ + transaction_date: string; + amount: number; + description: string; + }>, + ): Promise { + try { + if (transactions.length === 0) { + return []; + } + + const dates = transactions.map((t) => t.transaction_date); + const minDate = dates.reduce((a, b) => (a < b ? a : b)); + const maxDate = dates.reduce((a, b) => (a > b ? a : b)); + + const allTransactions = await this.getByAccountId(accountId); + const transactionsInRange = allTransactions.filter( + (t) => + t.transaction_date >= minDate && + t.transaction_date <= maxDate, + ); + + console.log( + `Checking duplicates for ${transactions.length} transactions. Found ${transactionsInRange.length} existing transactions between ${minDate} and ${maxDate}`, + ); + + const keyString = getStoredKey(); + if (!keyString) { + console.warn('No encryption key found for duplicate check'); + return transactions.map(() => false); + } + + const key = await importKey(keyString); + const { decrypt } = await import('@/lib/crypto'); + + const decryptedTransactions = await Promise.all( + transactionsInRange.map(async (t) => { + try { + const decryptedDescription = await decrypt( + t.description, + key, + t.description_iv, + ); + return { + transaction_date: t.transaction_date, + amount: parseFloat(t.amount), + description: decryptedDescription + .toLowerCase() + .trim() + .replace(/\s+/g, ' '), + }; + } catch (error) { + console.error( + 'Failed to decrypt transaction:', + t.id, + error, + ); + return null; + } + }), + ); + + const validDecryptedTransactions = decryptedTransactions.filter( + (t) => t !== null, + ); + + console.log( + `Successfully decrypted ${validDecryptedTransactions.length} transactions`, + ); + + const results = transactions.map((importingTx) => { + const normalizedDescription = importingTx.description + .toLowerCase() + .trim() + .replace(/\s+/g, ' '); + + const isDuplicate = validDecryptedTransactions.some( + (existing) => + existing.transaction_date === + importingTx.transaction_date && + Math.abs(existing.amount - importingTx.amount) < + 0.001 && + existing.description === normalizedDescription, + ); + + return isDuplicate; + }); + + const duplicateCount = results.filter((r) => r).length; + console.log( + `Found ${duplicateCount} duplicates out of ${transactions.length} transactions`, + ); + + return results; + } catch (error) { + console.warn( + 'Duplicate check failed, assuming no duplicates:', + error, + ); + return transactions.map(() => false); } } @@ -150,48 +283,17 @@ class TransactionSyncService { accountId: number, transactionDate: string, amount: number, - description: string + description: string, ): Promise { - try { - const existingTransactions = await this.getByAccountId(accountId); - - const keyString = getStoredKey(); - if (!keyString) { - return false; - } - - const key = await importKey(keyString); - - for (const existing of existingTransactions) { - if ( - existing.transaction_date === transactionDate && - parseFloat(existing.amount) === amount - ) { - try { - const { decrypt } = await import('@/lib/crypto'); - const decryptedExisting = await decrypt( - existing.description, - key, - existing.description_iv - ); - - if (decryptedExisting.toLowerCase() === description.toLowerCase()) { - return true; - } - } catch (error) { - console.error('Failed to decrypt description for duplicate check:', error); - } - } - } - - return false; - } catch (error) { - console.warn('Duplicate check failed, assuming no duplicates:', error); - return false; - } + const results = await this.checkDuplicates(accountId, [ + { transaction_date: transactionDate, amount, description }, + ]); + return results[0] || false; } - async encryptDescription(description: string): Promise<{ encrypted: string; iv: string }> { + async encryptDescription( + description: string, + ): Promise<{ encrypted: string; iv: string }> { const keyString = getStoredKey(); if (!keyString) { throw new Error('Encryption key not set'); @@ -211,4 +313,3 @@ class TransactionSyncService { } export const transactionSyncService = new TransactionSyncService(); -