import { encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; import { TransactionSyncManager } from '@/lib/sync-manager'; import type { Transaction } from '@/types/transaction'; import type { UUID } from '@/types/uuid'; import axios from 'axios'; interface TransactionUpdateData extends Partial { label_ids?: string[]; } interface TransactionFilters { dateFrom?: Date | null; dateTo?: Date | null; amountMin?: number | null; amountMax?: number | null; categoryIds?: number[]; accountIds?: string[]; labelIds?: string[]; searchText?: string; } function getCsrfToken(): string { return decodeURIComponent( document.cookie .split('; ') .find((row) => row.startsWith('XSRF-TOKEN=')) ?.split('=')[1] || '', ); } class TransactionSyncService { private syncManager: TransactionSyncManager; constructor() { this.syncManager = new TransactionSyncManager({ endpoint: '/api/sync/transactions', transformFromServer: (data) => { const label_ids = data.labels?.map((l: { id: string }) => l.id); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { labels, ...rest } = data; return { ...rest, transaction_date: String(data.transaction_date).slice( 0, 10, ), label_ids: label_ids || [], }; }, }); } async sync() { return await this.syncManager.sync(); } async getAll(): Promise { return await this.syncManager.getAll(); } async getById(id: UUID): Promise { return await this.syncManager.getById(id); } async getByAccountId(accountId: UUID): Promise { return await this.syncManager.getByAccountId(accountId); } async create( data: Omit, ): Promise { const response = await axios.post('/api/sync/transactions', data); const serverData = response.data.data; const label_ids = serverData.labels?.map((l: { id: string }) => l.id); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { labels, ...rest } = serverData; return { ...rest, transaction_date: String(serverData.transaction_date).slice(0, 10), label_ids: label_ids || [], } as Transaction; } async createMany( transactions: Omit[], ): Promise { const created: Transaction[] = []; for (const data of transactions) { const transaction = await this.create(data); created.push(transaction); } return created; } async update( id: string, data: TransactionUpdateData, ): Promise { const existing = await this.getById(id); if (!existing) { throw new Error('Transaction not found'); } const { label_ids, ...transactionData } = data; const response = await fetch(`/api/sync/transactions/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-XSRF-TOKEN': getCsrfToken(), }, credentials: 'same-origin', body: JSON.stringify({ ...existing, ...transactionData, label_ids, }), }); if (!response.ok) { throw new Error('Failed to update transaction'); } const result = await response.json(); const serverData = result.data; const serverLabelIds = serverData.labels?.map( (l: { id: string }) => l.id, ); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { labels: _labels, ...restServerData } = serverData; return { ...restServerData, transaction_date: String(serverData.transaction_date).slice(0, 10), label_ids: serverLabelIds || [], } as Transaction; } async updateMany( ids: string[], data: TransactionUpdateData, ): Promise { const { label_ids, ...transactionData } = data; const response = await fetch('/transactions/bulk', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-XSRF-TOKEN': getCsrfToken(), }, credentials: 'same-origin', body: JSON.stringify({ transaction_ids: ids, label_ids: label_ids, ...transactionData, }), }); if (!response.ok) { throw new Error('Failed to bulk update transactions'); } } async updateByFilters( filters: TransactionFilters, data: TransactionUpdateData, ): Promise { const { label_ids, ...transactionData } = data; const requestFilters: Record = {}; if (filters.dateFrom) { requestFilters.date_from = filters.dateFrom .toISOString() .split('T')[0]; } if (filters.dateTo) { requestFilters.date_to = filters.dateTo.toISOString().split('T')[0]; } if (filters.amountMin !== null && filters.amountMin !== undefined) { requestFilters.amount_min = filters.amountMin; } if (filters.amountMax !== null && filters.amountMax !== undefined) { requestFilters.amount_max = filters.amountMax; } if (filters.categoryIds && filters.categoryIds.length > 0) { requestFilters.category_ids = filters.categoryIds; } if (filters.accountIds && filters.accountIds.length > 0) { requestFilters.account_ids = filters.accountIds; } if (filters.labelIds && filters.labelIds.length > 0) { requestFilters.label_ids = filters.labelIds; } const response = await fetch('/transactions/bulk', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-XSRF-TOKEN': getCsrfToken(), }, credentials: 'same-origin', body: JSON.stringify({ filters: requestFilters, label_ids: label_ids, ...transactionData, }), }); if (!response.ok) { throw new Error('Failed to bulk update transactions by filters'); } const result = await response.json(); return result.count || 0; } async delete(id: string): Promise { await axios.delete(`/api/sync/transactions/${id}`); } async updateManyIndividual( updates: Array<{ id: string; data: TransactionUpdateData }>, ): Promise { for (const { id, data } of updates) { await this.update(id, data); } } async deleteMany(ids: string[]): Promise { for (const id of ids) { await this.delete(id); } } async checkDuplicates( accountId: string, 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 normalizeDate = (dateStr: string): string => dateStr.slice(0, 10); const allTransactions = await this.getByAccountId(accountId); const transactionsInRange = allTransactions.filter((t) => { const txDate = normalizeDate(t.transaction_date); return txDate >= minDate && txDate <= 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: normalizeDate(t.transaction_date), amount: parseFloat(t.amount), description: decryptedDescription .toLowerCase() .trim() .replace(/\s+/g, ' '), }; } catch { return null; } }), ); const validDecryptedTransactions = decryptedTransactions.filter( (t) => t !== null, ); return transactions.map((importingTx) => { const normalizedDescription = importingTx.description .toLowerCase() .trim() .replace(/\s+/g, ' '); return validDecryptedTransactions.some( (existing) => existing.transaction_date === importingTx.transaction_date && Math.abs(existing.amount - importingTx.amount) < 0.001 && existing.description === normalizedDescription, ); }); } catch (error) { console.warn( 'Duplicate check failed, assuming no duplicates:', error, ); return transactions.map(() => false); } } async encryptDescription( description: string, ): Promise<{ encrypted: string; iv: string }> { const keyString = getStoredKey(); if (!keyString) { throw new Error('Encryption key not set'); } const key = await importKey(keyString); return await encrypt(description, key); } async getLastSyncTime(): Promise { return await this.syncManager.getLastSyncTime(); } isSyncing(): boolean { return this.syncManager.isSyncing(); } async clearAll(): Promise { await this.syncManager.clearAll(); } } export const transactionSyncService = new TransactionSyncService();