import { index as indexBalances, store as storeBalance, } from '@/actions/App/Http/Controllers/AccountBalanceController'; import { LabelCombobox } from '@/components/shared/label-combobox'; import { CategorySelect } from '@/components/transactions/category-select'; import { AmountInput } from '@/components/ui/amount-input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label as FormLabel } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useSyncContext } from '@/contexts/sync-context'; import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; import { filterTransactionalAccounts, type Account, type Bank, } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; import { type DecryptedTransaction } from '@/types/transaction'; import { format, getYear, parseISO } from 'date-fns'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; interface EditTransactionDialogProps { transaction: DecryptedTransaction | null; categories: Category[]; accounts: Account[]; banks: Bank[]; labels: Label[]; automationRules?: AutomationRule[]; open: boolean; onOpenChange: (open: boolean) => void; onSuccess: (transaction: DecryptedTransaction) => void; mode: 'create' | 'edit'; } export function EditTransactionDialog({ transaction, categories, accounts, banks, labels, automationRules = [], open, onOpenChange, onSuccess, mode, }: EditTransactionDialogProps) { const STORAGE_KEY_UPDATE_BALANCE = 'whisper_money_update_balance_on_transaction'; const { isKeySet } = useEncryptionKey(); const { sync } = useSyncContext(); const [transactionDate, setTransactionDate] = useState(''); const [description, setDescription] = useState(''); const [amount, setAmount] = useState(0); const [accountId, setAccountId] = useState(''); const [categoryId, setCategoryId] = useState('null'); const [selectedLabelIds, setSelectedLabelIds] = useState([]); const [notes, setNotes] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [decryptedAccountNames, setDecryptedAccountNames] = useState< Map >(new Map()); const [updateAccountBalance, setUpdateAccountBalance] = useState(() => { if (typeof window !== 'undefined') { const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE); return stored === 'true'; } return false; }); useEffect(() => { if (mode === 'edit' && transaction) { setTransactionDate(transaction.transaction_date); setDescription(transaction.decryptedDescription); setAmount(transaction.amount); setAccountId(transaction.account_id); setCategoryId(transaction.category_id || 'null'); setSelectedLabelIds( transaction.label_ids || transaction.labels?.map((l) => l.id) || [], ); setNotes(transaction.decryptedNotes || ''); } else if (mode === 'create' && open) { const today = new Date().toISOString().split('T')[0]; setTransactionDate(today); setDescription(''); setAmount(0); const availableAccounts = filterTransactionalAccounts(accounts); setAccountId( availableAccounts.length > 0 ? availableAccounts[0].id : '', ); setCategoryId('null'); setSelectedLabelIds([]); setNotes(''); } }, [mode, transaction, open, accounts]); useEffect(() => { if (!open || mode !== 'create') return; async function decryptAccountNames() { const keyString = getStoredKey(); if (!keyString) { return; } try { const key = await importKey(keyString); const decryptedNames = new Map(); await Promise.all( accounts.map(async (account) => { try { const decryptedName = await decrypt( account.name, key, account.name_iv, ); decryptedNames.set(account.id, decryptedName); } catch (error) { console.error( 'Failed to decrypt account name:', account.id, error, ); decryptedNames.set(account.id, '[Encrypted]'); } }), ); setDecryptedAccountNames(decryptedNames); } catch (error) { console.error('Failed to decrypt account names:', error); } } decryptAccountNames(); }, [open, mode, accounts]); async function checkAndApplyAutomationRules() { if (mode !== 'create' || automationRules.length === 0) { return { categoryId: null, notes: null, notesIv: null, ruleName: null, }; } const keyString = getStoredKey(); if (!keyString) { return { categoryId: null, notes: null, notesIv: null, ruleName: null, }; } const key = await importKey(keyString); const result = await evaluateRulesForNewTransaction( { description: description.trim(), amount: amount / 100, transaction_date: transactionDate, account_id: accountId, notes: notes.trim() || undefined, }, automationRules, categories, accounts, banks, key, ); if (!result) { return { categoryId: null, notes: null, notesIv: null, ruleName: null, }; } let finalNotes = notes.trim(); const finalNotesIv = null; if (result.note && result.noteIv) { const decryptedRuleNote = await decrypt( result.note, key, result.noteIv, ); finalNotes = appendNoteIfNotPresent( finalNotes || undefined, decryptedRuleNote, ); } return { categoryId: result.categoryId, notes: finalNotes || null, notesIv: finalNotesIv, ruleName: result.rule.title, }; } function handleUpdateBalanceChange(checked: boolean) { setUpdateAccountBalance(checked); localStorage.setItem(STORAGE_KEY_UPDATE_BALANCE, String(checked)); } async function updateBalanceForTransaction( accountIdToUpdate: string, transactionDateStr: string, transactionAmount: number, ) { const xsrfToken = decodeURIComponent( document.cookie .split('; ') .find((row) => row.startsWith('XSRF-TOKEN=')) ?.split('=')[1] || '', ); try { // Fetch balances from backend const balancesResponse = await fetch( indexBalances.url(accountIdToUpdate), { headers: { Accept: 'application/json', }, }, ); if (!balancesResponse.ok) { throw new Error('Failed to fetch balances'); } const balancesData = await balancesResponse.json(); const accountBalances = (balancesData.data || []).sort( (a: { balance_date: string }, b: { balance_date: string }) => new Date(b.balance_date).getTime() - new Date(a.balance_date).getTime(), ); const latestBalance = accountBalances.length > 0 ? accountBalances[0].balance : 0; const newBalance = latestBalance + transactionAmount; // Store new balance via backend const storeResponse = await fetch( storeBalance.url(accountIdToUpdate), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken, Accept: 'application/json', }, body: JSON.stringify({ balance_date: transactionDateStr, balance: newBalance, }), }, ); if (!storeResponse.ok) { throw new Error('Failed to store balance'); } } catch (error) { console.error('Failed to update account balance:', error); toast.error('Transaction created, but failed to update balance'); } } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!isKeySet) { toast.error( 'Please unlock your encryption key to save transactions', ); return; } if (mode === 'create') { if (!description.trim()) { toast.error('Description is required'); return; } if (amount === 0) { toast.error('Amount is required'); return; } if (!accountId) { toast.error('Account is required'); return; } if (!transactionDate) { toast.error('Date is required'); return; } } else if ( mode === 'edit' && transaction?.source === 'manually_created' ) { if (!description.trim()) { toast.error('Description is required'); return; } } setIsSubmitting(true); try { const trimmedDescription = description.trim(); const keyString = getStoredKey(); if (!keyString) { throw new Error('Encryption key not available'); } const key = await importKey(keyString); if (mode === 'create') { const ruleResult = await checkAndApplyAutomationRules(); let finalCategoryId = categoryId === 'null' ? null : categoryId; let finalNotes = notes.trim(); if (ruleResult.categoryId) { finalCategoryId = ruleResult.categoryId; } if (ruleResult.notes) { finalNotes = ruleResult.notes; } let encryptedNotes: string | null = null; let notesIv: string | null = null; if (finalNotes) { const encrypted = await encrypt(finalNotes, key); encryptedNotes = encrypted.encrypted; notesIv = encrypted.iv; } const encryptedDescription = await encrypt( trimmedDescription, key, ); const selectedAccount = accounts.find( (acc) => acc.id === accountId, ); if (!selectedAccount) { throw new Error('Selected account not found'); } const createdTransaction = await transactionSyncService.create({ user_id: '00000000-0000-0000-0000-000000000000', account_id: accountId, category_id: finalCategoryId, description: encryptedDescription.encrypted, description_iv: encryptedDescription.iv, transaction_date: transactionDate, amount: amount, currency_code: selectedAccount.currency_code, notes: encryptedNotes, notes_iv: notesIv, source: 'manually_created' as const, }); const updatedCategory = finalCategoryId ? categories.find( (category) => category.id === finalCategoryId, ) || null : null; const newTransaction: DecryptedTransaction = { ...createdTransaction, decryptedDescription: trimmedDescription, decryptedNotes: finalNotes || null, category: updatedCategory, account: selectedAccount, bank: selectedAccount.bank?.id ? banks.find((b) => b.id === selectedAccount.bank?.id) : undefined, }; if (updateAccountBalance) { await updateBalanceForTransaction( accountId, transactionDate, amount, ); } toast.success('Transaction created successfully'); if (ruleResult.ruleName) { toast.success(`Rule "${ruleResult.ruleName}" applied`); } onSuccess(newTransaction); onOpenChange(false); // Sync to update IndexedDB sync(); } else { if (!transaction) { return; } const selectedCategoryId = categoryId === 'null' ? null : categoryId; const trimmedNotes = notes.trim(); const trimmedDescription = description.trim(); let encryptedNotes: string | null = null; let notesIv: string | null = null; if (trimmedNotes) { const encrypted = await encrypt(trimmedNotes, key); encryptedNotes = encrypted.encrypted; notesIv = encrypted.iv; } const updateData: { category_id: string | null; notes: string | null; notes_iv: string | null; description?: string; description_iv?: string; label_ids?: string[]; } = { category_id: selectedCategoryId, notes: encryptedNotes, notes_iv: notesIv, label_ids: selectedLabelIds, }; let finalDecryptedDescription = transaction.decryptedDescription; if ( transaction.source === 'manually_created' && trimmedDescription ) { const encryptedDescription = await encrypt( trimmedDescription, key, ); updateData.description = encryptedDescription.encrypted; updateData.description_iv = encryptedDescription.iv; finalDecryptedDescription = trimmedDescription; } await transactionSyncService.update(transaction.id, updateData); const updatedRecord = await transactionSyncService.getById( transaction.id, ); const updatedCategory = selectedCategoryId ? categories.find( (category) => category.id === selectedCategoryId, ) || null : null; const selectedLabels = labels.filter((label) => selectedLabelIds.includes(label.id), ); const updatedTransaction: DecryptedTransaction = { ...transaction, category_id: selectedCategoryId, category: updatedCategory, decryptedDescription: finalDecryptedDescription, description: updateData.description ?? transaction.description, description_iv: updateData.description_iv ?? transaction.description_iv, decryptedNotes: trimmedNotes || null, notes: encryptedNotes, notes_iv: notesIv, label_ids: selectedLabelIds, labels: selectedLabels, updated_at: updatedRecord?.updated_at ?? transaction.updated_at, }; toast.success('Transaction updated successfully'); onSuccess(updatedTransaction); onOpenChange(false); // Sync to update IndexedDB sync(); } } catch (error) { console.error('Failed to save transaction:', error); toast.error( `Failed to ${mode === 'create' ? 'create' : 'update'} transaction`, ); } finally { setIsSubmitting(false); } } const selectedAccount = accounts.find((acc) => acc.id === accountId); const transactionalAccounts = filterTransactionalAccounts(accounts); return ( {mode === 'create' ? 'Add Transaction' : 'Edit Transaction'} {mode === 'create' ? 'Create a new transaction.' : 'Update the category and notes for this transaction.'}
Date {mode === 'create' ? ( setTransactionDate(e.target.value) } disabled={isSubmitting} required /> ) : (
{transaction && (() => { const date = parseISO( transaction.transaction_date, ); const currentYear = getYear( new Date(), ); const transactionYear = getYear(date); const formatString = transactionYear === currentYear ? 'MMMM d' : 'MMMM d, yyyy'; return format(date, formatString); })()}
)}
Description {mode === 'create' || (mode === 'edit' && transaction?.source === 'manually_created') ? (