diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 7ba2ba6b..b7e30b63 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -42,6 +42,8 @@ class TransactionController extends Controller 'category_ids' => $validated['category_ids'] ?? null, 'account_ids' => $validated['account_ids'] ?? null, 'label_ids' => $validated['label_ids'] ?? null, + 'creditor_name' => $validated['creditor_name'] ?? null, + 'debtor_name' => $validated['debtor_name'] ?? null, 'search' => $validated['search'] ?? null, ], fn ($value) => $value !== null); @@ -62,6 +64,8 @@ class TransactionController extends Controller 'category_ids' => $validated['category_ids'] ?? [], 'account_ids' => $validated['account_ids'] ?? [], 'label_ids' => $validated['label_ids'] ?? [], + 'creditor_name' => $validated['creditor_name'] ?? '', + 'debtor_name' => $validated['debtor_name'] ?? '', 'search' => $validated['search'] ?? '', 'sort' => $sortParam, ]; diff --git a/app/Http/Requests/BulkReEvaluateRulesRequest.php b/app/Http/Requests/BulkReEvaluateRulesRequest.php index f464e0b2..393018e0 100644 --- a/app/Http/Requests/BulkReEvaluateRulesRequest.php +++ b/app/Http/Requests/BulkReEvaluateRulesRequest.php @@ -27,6 +27,8 @@ class BulkReEvaluateRulesRequest extends FormRequest 'filters.account_ids.*' => ['string', 'uuid'], 'filters.label_ids' => ['nullable', 'array'], 'filters.label_ids.*' => ['string', 'uuid'], + 'filters.creditor_name' => ['nullable', 'string'], + 'filters.debtor_name' => ['nullable', 'string'], 'filters.search' => ['nullable', 'string'], ]; } diff --git a/app/Http/Requests/BulkUpdateTransactionsRequest.php b/app/Http/Requests/BulkUpdateTransactionsRequest.php index d5de9a1c..e1ca93c0 100644 --- a/app/Http/Requests/BulkUpdateTransactionsRequest.php +++ b/app/Http/Requests/BulkUpdateTransactionsRequest.php @@ -40,6 +40,9 @@ class BulkUpdateTransactionsRequest extends FormRequest $query->where('user_id', $this->user()->id); }), ], + 'filters.creditor_name' => ['nullable', 'string'], + 'filters.debtor_name' => ['nullable', 'string'], + 'filters.search' => ['nullable', 'string'], 'filters.search_text' => ['nullable', 'string'], 'category_id' => [ 'nullable', diff --git a/app/Http/Requests/IndexTransactionRequest.php b/app/Http/Requests/IndexTransactionRequest.php index 8c658a91..861a3158 100644 --- a/app/Http/Requests/IndexTransactionRequest.php +++ b/app/Http/Requests/IndexTransactionRequest.php @@ -39,8 +39,10 @@ class IndexTransactionRequest extends FormRequest 'account_ids.*' => ['string', 'uuid'], 'label_ids' => ['nullable', 'array'], 'label_ids.*' => ['string', 'uuid'], + 'creditor_name' => ['nullable', 'string', 'max:255'], + 'debtor_name' => ['nullable', 'string', 'max:255'], 'search' => ['nullable', 'string', 'max:200'], - 'sort' => ['nullable', 'string', 'in:transaction_date,-transaction_date,amount,-amount,description,-description'], + 'sort' => ['nullable', 'string', 'in:transaction_date,-transaction_date,amount,-amount,description,-description,creditor_name,-creditor_name,debtor_name,-debtor_name'], 'cursor' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:10', 'max:100'], ]; diff --git a/app/Http/Requests/StoreTransactionRequest.php b/app/Http/Requests/StoreTransactionRequest.php index 0c87d1a5..117fd718 100644 --- a/app/Http/Requests/StoreTransactionRequest.php +++ b/app/Http/Requests/StoreTransactionRequest.php @@ -36,6 +36,8 @@ class StoreTransactionRequest extends FormRequest 'currency_code' => ['required', 'string', 'size:3'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], + 'creditor_name' => ['nullable', 'string', 'max:255'], + 'debtor_name' => ['nullable', 'string', 'max:255'], 'source' => ['required', Rule::enum(TransactionSource::class)], 'label_ids' => ['nullable', 'array'], 'label_ids.*' => [ diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php index 6820b8e4..70fc220e 100644 --- a/app/Http/Requests/UpdateTransactionRequest.php +++ b/app/Http/Requests/UpdateTransactionRequest.php @@ -25,6 +25,8 @@ class UpdateTransactionRequest extends FormRequest 'description_iv' => ['nullable', 'string', 'size:16'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], + 'creditor_name' => ['nullable', 'string', 'max:255'], + 'debtor_name' => ['nullable', 'string', 'max:255'], 'label_ids' => ['nullable', 'array'], 'label_ids.*' => [ 'required', diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 0c03cdea..eb99543f 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -49,6 +49,8 @@ class Transaction extends Model 'external_transaction_id', 'dedup_fingerprint', 'raw_data', + 'creditor_name', + 'debtor_name', ]; protected function casts(): array @@ -138,9 +140,23 @@ class Transaction extends Model $query->whereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $filters['label_ids'])); } + if (! empty($filters['creditor_name'])) { + $term = '%'.$filters['creditor_name'].'%'; + $query->where('creditor_name', 'LIKE', $term); + } + + if (! empty($filters['debtor_name'])) { + $term = '%'.$filters['debtor_name'].'%'; + $query->where('debtor_name', 'LIKE', $term); + } + if (! empty($filters['search'])) { $term = '%'.$filters['search'].'%'; - $query->where(fn (Builder $q) => $q->where('description', 'LIKE', $term)->orWhere('notes', 'LIKE', $term)); + $query->where(fn (Builder $q) => $q + ->where('description', 'LIKE', $term) + ->orWhere('notes', 'LIKE', $term) + ->orWhere('creditor_name', 'LIKE', $term) + ->orWhere('debtor_name', 'LIKE', $term)); } return $query; diff --git a/app/Services/AutomationRuleService.php b/app/Services/AutomationRuleService.php index 8c64622b..2069d415 100644 --- a/app/Services/AutomationRuleService.php +++ b/app/Services/AutomationRuleService.php @@ -184,7 +184,7 @@ class AutomationRuleService } /** - * @return array{description: string, amount: float, transaction_date: string, bank_name: string, account_name: string, category: string|null, notes: string|null} + * @return array{description: string, amount: float, transaction_date: string, bank_name: string, account_name: string, category: string|null, notes: string|null, creditor_name: string|null, debtor_name: string|null} */ private function prepareTransactionData(Transaction $transaction, ?AutomationRule $rule = null): array { @@ -209,6 +209,12 @@ class AutomationRuleService 'notes' => $transaction->notes ? $this->normalizeWhitespace(mb_strtolower($transaction->notes)) : null, + 'creditor_name' => $transaction->creditor_name + ? $this->normalizeWhitespace(mb_strtolower($transaction->creditor_name)) + : null, + 'debtor_name' => $transaction->debtor_name + ? $this->normalizeWhitespace(mb_strtolower($transaction->debtor_name)) + : null, ]; } diff --git a/app/Services/Banking/TransactionCounterpartyExtractor.php b/app/Services/Banking/TransactionCounterpartyExtractor.php new file mode 100644 index 00000000..5408bdda --- /dev/null +++ b/app/Services/Banking/TransactionCounterpartyExtractor.php @@ -0,0 +1,39 @@ + $data + * @return array{creditor_name: string|null, debtor_name: string|null} + */ + public static function fromPayload(array $data): array + { + return [ + 'creditor_name' => self::name($data['creditor']['name'] ?? null), + 'debtor_name' => self::name($data['debtor']['name'] ?? null), + ]; + } + + private static function name(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $name = preg_replace('/[;\s]+/u', ' ', trim($value)); + + if (! is_string($name)) { + return null; + } + + $name = trim($name); + + if ($name === '' || preg_match('/[\pL\pN]/u', $name) !== 1) { + return null; + } + + return mb_substr($name, 0, 255); + } +} diff --git a/app/Services/Banking/TransactionSyncService.php b/app/Services/Banking/TransactionSyncService.php index a03b627c..95c6a005 100644 --- a/app/Services/Banking/TransactionSyncService.php +++ b/app/Services/Banking/TransactionSyncService.php @@ -106,6 +106,7 @@ class TransactionSyncService $amount = $this->parseAmount($data); $rawDescription = $this->parseDescription($data); $formatted = $this->descriptionFormatter->format($rawDescription, $bankName); + $counterparties = TransactionCounterpartyExtractor::fromPayload($data); $transactionDate = $this->parseDate($data); $currency = $data['transaction_amount']['currency'] ?? $account->currency_code; @@ -124,6 +125,7 @@ class TransactionSyncService 'external_transaction_id' => $externalId, 'dedup_fingerprint' => $fingerprint, 'raw_data' => $data, + ...$counterparties, ]); } catch (UniqueConstraintViolationException) { // Concurrent sync inserted the same fingerprint between our diff --git a/database/migrations/2026_05_27_061513_add_counterparty_names_to_transactions_table.php b/database/migrations/2026_05_27_061513_add_counterparty_names_to_transactions_table.php new file mode 100644 index 00000000..7a83dc1a --- /dev/null +++ b/database/migrations/2026_05_27_061513_add_counterparty_names_to_transactions_table.php @@ -0,0 +1,57 @@ +string('creditor_name')->nullable()->after('raw_data'); + $table->string('debtor_name')->nullable()->after('creditor_name'); + $table->index('creditor_name'); + $table->index('debtor_name'); + }); + + DB::table('transactions') + ->select(['id', 'raw_data']) + ->whereNotNull('raw_data') + ->orderBy('id') + ->chunk(500, function ($transactions): void { + foreach ($transactions as $transaction) { + if (! is_string($transaction->raw_data)) { + continue; + } + + $rawData = json_decode($transaction->raw_data, true); + + if (! is_array($rawData)) { + continue; + } + + $counterparties = TransactionCounterpartyExtractor::fromPayload($rawData); + + if ($counterparties['creditor_name'] === null && $counterparties['debtor_name'] === null) { + continue; + } + + DB::table('transactions') + ->where('id', $transaction->id) + ->update($counterparties); + } + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropIndex(['creditor_name']); + $table->dropIndex(['debtor_name']); + $table->dropColumn(['creditor_name', 'debtor_name']); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index ead5139a..dfcfea90 100644 --- a/lang/es.json +++ b/lang/es.json @@ -377,6 +377,7 @@ "Connecting...": "Conectando...", "Connections": "Conexiones", "Consider saving more if possible.": "Considera ahorrar más si es posible.", + "Counterparties": "Contrapartes", "Continue": "Continuar", "Continue Setup": "Continuar Configuración", "Continue for free": "Continuar gratis", @@ -424,6 +425,10 @@ "Credentials updated. Sync started.": "Credenciales actualizadas. Sincronización iniciada.", "Credit": "Crédito", "Credit Card": "Tarjeta de Crédito", + "Creditor": "Acreedor", + "Creditor Name": "Nombre del acreedor", + "Creditor name": "Nombre del acreedor", + "Creditor name (Optional)": "Nombre del acreedor (opcional)", "Crunching the numbers...": "Calculando los números...", "Ctrl+B": "Ctrl+B", "Ctrl+N": "Ctrl+N", @@ -448,6 +453,10 @@ "Date Format": "Formato de Fecha", "Date is required": "Se requiere una fecha", "David K.": "David K.", + "Debtor": "Deudor", + "Debtor Name": "Nombre del deudor", + "Debtor name": "Nombre del deudor", + "Debtor name (Optional)": "Nombre del deudor (opcional)", "Day of the month when the period starts (1-31)": "Día del mes en que comienza el período (1-31)", "Day of week (0=Sunday, 6=Saturday)": "Día de la semana (0=Domingo, 6=Sábado)", "Define monthly budgets by category. Know exactly how much you can spend.": "Define presupuestos mensuales por categoría. Sabe exactamente cuánto puedes gastar.", @@ -1179,8 +1188,10 @@ "Select bank...": "Seleccionar banco...", "Select categories...": "Seleccionar categorías...", "Select country": "Seleccionar país", + "Select creditor column (optional)": "Seleccionar columna de acreedor (opcional)", "Select currency": "Seleccionar moneda", "Select date column": "Seleccionar columna de fecha", + "Select debtor column (optional)": "Seleccionar columna de deudor (opcional)", "Select description column": "Seleccionar columna de descripción", "Select invested amount column (optional)": "Seleccionar columna de cantidad invertida (opcional)", "Select labels": "Seleccionar etiquetas", diff --git a/resources/js/components/transactions/edit-transaction-dialog.test.tsx b/resources/js/components/transactions/edit-transaction-dialog.test.tsx new file mode 100644 index 00000000..8333f848 --- /dev/null +++ b/resources/js/components/transactions/edit-transaction-dialog.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EditTransactionDialog } from './edit-transaction-dialog'; + +vi.mock('@/actions/App/Http/Controllers/AccountBalanceController', () => ({ + indexBalances: () => ({ url: '/balances' }), + store: () => ({ url: '/balances' }), + index: () => ({ url: '/balances' }), +})); + +vi.mock('@/components/shared/label-combobox', () => ({ + LabelCombobox: () =>
, +})); + +vi.mock('@/components/transactions/category-select', () => ({ + CategorySelect: () =>
, +})); + +vi.mock('@/contexts/sync-context', () => ({ + useSyncContext: () => ({ sync: vi.fn() }), +})); + +vi.mock('@/hooks/use-locale', () => ({ + useLocale: () => 'en-US', +})); + +vi.mock('@/lib/key-storage', () => ({ + getStoredKey: () => null, +})); + +vi.mock('@/lib/crypto', () => ({ + decrypt: vi.fn(), + importKey: vi.fn(), +})); + +vi.mock('@/lib/rule-engine', () => ({ + evaluateRulesForNewTransaction: vi.fn(), +})); + +vi.mock('@/services/transaction-sync', () => ({ + transactionSyncService: { + create: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ + children, + open, + }: { + children: React.ReactNode; + open: boolean; + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +describe('EditTransactionDialog', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }, + configurable: true, + }); + }); + + it('shows counterparty names as read-only fields', () => { + render( + , + ); + + expect(screen.getByText('Creditor')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Amazon EU')).toBeDisabled(); + expect(screen.getByText('Debtor')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Victor Falcon')).toBeDisabled(); + }); +}); diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 32fe8da4..25b2bbe2 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -420,6 +420,8 @@ export function EditTransactionDialog({ currency_code: selectedAccount.currency_code, notes: encryptedNotes, notes_iv: notesIv, + creditor_name: null, + debtor_name: null, source: 'manually_created' as const, label_ids: finalLabelIds.length > 0 ? finalLabelIds : undefined, @@ -702,6 +704,42 @@ export function EditTransactionDialog({ )}
+ {mode === 'edit' && + (transaction?.creditor_name || + transaction?.debtor_name) && ( +
+ {transaction.creditor_name && ( +
+ + {__('Creditor')} + + +
+ )} + + {transaction.debtor_name && ( +
+ + {__('Debtor')} + + +
+ )} +
+ )} +
{ @@ -422,10 +418,7 @@ export function ImportStepMapping({
+
+
+ + +
+ +
+ + +
+
+ {baseMappingValid && previewTransactions.length > 0 && (
+
+ + {__('Counterparties')} + +
+ + setCreditorName(e.target.value) + } + placeholder={__('Creditor name')} + /> + + setDebtorName(e.target.value) + } + placeholder={__('Debtor name')} + /> +
+
+
{__('Categories')}
diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index bcb9f784..e98ee4ff 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -181,6 +181,8 @@ function getInitialColumnVisibility(): VisibilityState { const defaultVisibility = { transaction_date: true, account: true, + creditor_name: false, + debtor_name: false, labels: true, notes: false, }; @@ -293,6 +295,8 @@ export function TransactionList({ categoryIds: [], accountIds: accountId ? [accountId] : [], labelIds: [], + creditorName: '', + debtorName: '', searchText: '', }); const [editTransaction, setEditTransaction] = @@ -811,6 +815,24 @@ export function TransactionList({ return false; } + if ( + filters.creditorName && + !transaction.creditor_name + ?.toLowerCase() + .includes(filters.creditorName.toLowerCase()) + ) { + return false; + } + + if ( + filters.debtorName && + !transaction.debtor_name + ?.toLowerCase() + .includes(filters.debtorName.toLowerCase()) + ) { + return false; + } + return true; }); }, [transactions, filters, isKeySet, accountId, searchMatchedIds]); @@ -844,6 +866,14 @@ export function TransactionList({ const categoryA = a.category?.name || ''; const categoryB = b.category?.name || ''; comparison = categoryA.localeCompare(categoryB); + } else if (id === 'creditor_name') { + comparison = (a.creditor_name || '').localeCompare( + b.creditor_name || '', + ); + } else if (id === 'debtor_name') { + comparison = (a.debtor_name || '').localeCompare( + b.debtor_name || '', + ); } if (comparison !== 0) { diff --git a/resources/js/lib/file-parser.test.ts b/resources/js/lib/file-parser.test.ts index 2580cc4f..4fcfa45b 100644 --- a/resources/js/lib/file-parser.test.ts +++ b/resources/js/lib/file-parser.test.ts @@ -1,13 +1,14 @@ +import type { ColumnMapping, ParsedTransaction } from '@/types/import'; import { DateFormat } from '@/types/import'; import { describe, expect, it } from 'vitest'; import { + autoDetectColumns, autoDetectDateFormat, calculateBalancesFromTransactions, convertRowsToTransactions, getLatestTransactionDate, getLocaleDateFormat, } from './file-parser'; -import type { ColumnMapping, ParsedTransaction } from '@/types/import'; describe('getLocaleDateFormat', () => { it('returns null for undefined locale', () => { @@ -58,6 +59,8 @@ describe('convertRowsToTransactions', () => { description: 'description', amount: 'amount', balance: null, + creditor_name: null, + debtor_name: null, }, DateFormat.DayMonthYear, ); @@ -70,6 +73,49 @@ describe('convertRowsToTransactions', () => { }); }); +describe('autoDetectColumns', () => { + it('detects creditor and debtor name columns', () => { + const mapping = autoDetectColumns([ + 'Transaction Date', + 'Description', + 'Amount', + 'Creditor Name', + 'Debtor Name', + ]); + + expect(mapping.creditor_name).toBe('Creditor Name'); + expect(mapping.debtor_name).toBe('Debtor Name'); + }); +}); + +describe('convertRowsToTransactions counterparty fields', () => { + it('maps optional creditor and debtor names', () => { + const transactions = convertRowsToTransactions( + [ + { + date: '2026-05-04', + description: 'Transfer', + amount: '10.00', + creditor: 'Landlord LLC', + debtor: 'Victor Falcon', + }, + ], + { + transaction_date: 'date', + description: 'description', + amount: 'amount', + balance: null, + creditor_name: 'creditor', + debtor_name: 'debtor', + }, + DateFormat.YearMonthDay, + ); + + expect(transactions[0].creditor_name).toBe('Landlord LLC'); + expect(transactions[0].debtor_name).toBe('Victor Falcon'); + }); +}); + describe('autoDetectDateFormat', () => { it('returns null for empty data', () => { expect(autoDetectDateFormat([], 'date')).toBeNull(); @@ -161,6 +207,8 @@ describe('getLatestTransactionDate', () => { description: 'desc', amount: 'amount', balance: null, + creditor_name: null, + debtor_name: null, }; it('returns null when no date column set', () => { @@ -193,10 +241,7 @@ describe('getLatestTransactionDate', () => { }); describe('calculateBalancesFromTransactions', () => { - function txn( - date: string, - amount: number, - ): ParsedTransaction { + function txn(date: string, amount: number): ParsedTransaction { return { transaction_date: date, description: 'x', @@ -224,10 +269,7 @@ describe('calculateBalancesFromTransactions', () => { }); it('handles reference date with no transactions on it', () => { - const txns = [ - txn('2024-01-01', 1000), - txn('2024-01-02', -200), - ]; + const txns = [txn('2024-01-01', 1000), txn('2024-01-02', -200)]; const balances = calculateBalancesFromTransactions( txns, '2024-01-05', diff --git a/resources/js/lib/file-parser.ts b/resources/js/lib/file-parser.ts index dbf8fb8b..c294698c 100644 --- a/resources/js/lib/file-parser.ts +++ b/resources/js/lib/file-parser.ts @@ -277,6 +277,8 @@ export function autoDetectColumns(headers: string[]): ColumnMapping { description: null, amount: null, balance: null, + creditor_name: null, + debtor_name: null, }; if (!headers || headers.length === 0) { @@ -328,6 +330,26 @@ export function autoDetectColumns(headers: string[]): ColumnMapping { 'saldo actual', 'saldo disponible', ]; + const creditorPatterns = [ + 'creditor', + 'creditor name', + 'beneficiary', + 'beneficiary name', + 'payee', + 'recipient', + 'contraparte acreedora', + 'acreedor', + ]; + const debtorPatterns = [ + 'debtor', + 'debtor name', + 'payer', + 'sender', + 'originator', + 'ordering party', + 'contraparte deudora', + 'deudor', + ]; for (let i = 0; i < lowerHeaders.length; i++) { const header = lowerHeaders[i]; @@ -361,6 +383,20 @@ export function autoDetectColumns(headers: string[]): ColumnMapping { ) { mapping.balance = originalHeader; } + + if ( + !mapping.creditor_name && + creditorPatterns.some((p) => header.includes(p)) + ) { + mapping.creditor_name = originalHeader; + } + + if ( + !mapping.debtor_name && + debtorPatterns.some((p) => header.includes(p)) + ) { + mapping.debtor_name = originalHeader; + } } return mapping; @@ -508,6 +544,19 @@ function getDescriptionFromRow(row: ParsedRow, mapping: ColumnMapping): string { .join('\n'); } +function getOptionalTextFromRow( + row: ParsedRow, + column: string | null, +): string | null { + if (!column) { + return null; + } + + const value = String(row[column] || '').trim(); + + return value.length > 0 ? value.slice(0, 255) : null; +} + export function validateTransaction( row: ParsedRow, mapping: ColumnMapping, @@ -581,6 +630,8 @@ export function convertRowsToTransactions( } const formattedDate = formatLocalDate(date); + const creditorName = getOptionalTextFromRow(row, mapping.creditor_name); + const debtorName = getOptionalTextFromRow(row, mapping.debtor_name); let balance: number | null = null; if (mapping.balance && row[mapping.balance]) { @@ -597,6 +648,8 @@ export function convertRowsToTransactions( description, amount: Math.round(amount * 100), balance, + creditor_name: creditorName, + debtor_name: debtorName, validationErrors: [], }); } diff --git a/resources/js/lib/rule-builder-utils.test.ts b/resources/js/lib/rule-builder-utils.test.ts index 2c9608c9..9c39f3de 100644 --- a/resources/js/lib/rule-builder-utils.test.ts +++ b/resources/js/lib/rule-builder-utils.test.ts @@ -16,6 +16,41 @@ describe('createDescriptionCondition', () => { }); }); +describe('buildJsonLogic', () => { + it('builds conditions for counterparty fields', () => { + const structure: RuleStructure = { + groupOperator: 'or', + groups: [ + { + id: 'group-1', + operator: 'and', + conditions: [ + { + id: 'condition-1', + field: 'creditor_name', + operator: 'contains', + value: 'amazon', + }, + { + id: 'condition-2', + field: 'debtor_name', + operator: 'is_not_empty', + value: '', + }, + ], + }, + ], + }; + + expect(buildJsonLogic(structure)).toMatchObject({ + and: [ + { in: ['amazon', { var: 'creditor_name' }] }, + { '!=': [{ var: 'debtor_name' }, null] }, + ], + }); + }); +}); + describe('addDescriptionMatchToRuleStructure', () => { it('adds a new description group to a simple rule', () => { const structure: RuleStructure = { diff --git a/resources/js/lib/rule-builder-utils.ts b/resources/js/lib/rule-builder-utils.ts index 2d262610..17657d94 100644 --- a/resources/js/lib/rule-builder-utils.ts +++ b/resources/js/lib/rule-builder-utils.ts @@ -45,6 +45,16 @@ export const FIELD_CONFIG: Record< type: 'string', operators: ['contains', 'equals'], }, + creditor_name: { + label: 'Creditor Name', + type: 'string', + operators: ['contains', 'equals', 'is_empty', 'is_not_empty'], + }, + debtor_name: { + label: 'Debtor Name', + type: 'string', + operators: ['contains', 'equals', 'is_empty', 'is_not_empty'], + }, account_name: { label: 'Account Name', type: 'string', diff --git a/resources/js/lib/rule-engine.ts b/resources/js/lib/rule-engine.ts index 157fc2b5..cde7965e 100644 --- a/resources/js/lib/rule-engine.ts +++ b/resources/js/lib/rule-engine.ts @@ -25,6 +25,8 @@ export interface TransactionData { account_name: string; category: string | null; notes: string | null; + creditor_name: string | null; + debtor_name: string | null; } function normalizeRuleJson(rulesJson: unknown): unknown { @@ -49,7 +51,10 @@ function normalizeRuleJson(rulesJson: unknown): unknown { typeof item === 'object' && item !== null && 'var' in item && - (item.var === 'description' || item.var === 'notes') + (item.var === 'description' || + item.var === 'notes' || + item.var === 'creditor_name' || + item.var === 'debtor_name') ) { return item; } @@ -127,6 +132,12 @@ export async function prepareTransactionData( notes: transaction.decryptedNotes ? normalizeWhitespace(transaction.decryptedNotes.toLowerCase()) : null, + creditor_name: transaction.creditor_name + ? normalizeWhitespace(transaction.creditor_name.toLowerCase()) + : null, + debtor_name: transaction.debtor_name + ? normalizeWhitespace(transaction.debtor_name.toLowerCase()) + : null, }; } @@ -229,6 +240,8 @@ export interface NewTransactionData { transaction_date: string; account_id: UUID; notes?: string; + creditor_name?: string | null; + debtor_name?: string | null; } export async function evaluateRulesForNewTransaction( @@ -279,6 +292,12 @@ export async function evaluateRulesForNewTransaction( notes: transactionData.notes ? normalizeWhitespace(transactionData.notes.toLowerCase()) : null, + creditor_name: transactionData.creditor_name + ? normalizeWhitespace(transactionData.creditor_name.toLowerCase()) + : null, + debtor_name: transactionData.debtor_name + ? normalizeWhitespace(transactionData.debtor_name.toLowerCase()) + : null, }; consoleDebug( diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index e1aab8dd..018390ab 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -110,6 +110,8 @@ interface AppliedFilters { category_ids: string[]; account_ids: string[]; label_ids: string[]; + creditor_name: string; + debtor_name: string; search: string; sort: string; } @@ -139,6 +141,8 @@ function serverToClientFilters(applied: AppliedFilters): Filters { categoryIds: applied.category_ids, accountIds: applied.account_ids, labelIds: applied.label_ids, + creditorName: applied.creditor_name, + debtorName: applied.debtor_name, searchText: applied.search, }; } @@ -170,6 +174,12 @@ function clientFiltersToQueryParams( if (filters.labelIds.length > 0) { params.label_ids = filters.labelIds.join(','); } + if (filters.creditorName) { + params.creditor_name = filters.creditorName; + } + if (filters.debtorName) { + params.debtor_name = filters.debtorName; + } if (filters.searchText) { params.search = filters.searchText; } @@ -206,6 +216,12 @@ function clientFiltersToBackendFilters( if (filters.labelIds.length > 0) { result.label_ids = filters.labelIds; } + if (filters.creditorName) { + result.creditor_name = filters.creditorName; + } + if (filters.debtorName) { + result.debtor_name = filters.debtorName; + } if (filters.searchText) { result.search = filters.searchText; } @@ -328,6 +344,8 @@ function getInitialColumnVisibility(): VisibilityState { const defaultVisibility = { transaction_date: true, account: true, + creditor_name: false, + debtor_name: false, labels: true, notes: false, }; @@ -515,7 +533,9 @@ export default function Transactions({ JSON.stringify(newFilters.accountIds) === JSON.stringify(filters.accountIds) && JSON.stringify(newFilters.labelIds) === - JSON.stringify(filters.labelIds); + JSON.stringify(filters.labelIds) && + newFilters.creditorName === filters.creditorName && + newFilters.debtorName === filters.debtorName; navigateWithFilters( newFilters, diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index a525831a..e3e0b36b 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -18,6 +18,8 @@ interface TransactionFilters { categoryIds?: number[]; accountIds?: string[]; labelIds?: string[]; + creditorName?: string; + debtorName?: string; searchText?: string; } @@ -158,6 +160,12 @@ class TransactionSyncService { if (filters.labelIds && filters.labelIds.length > 0) { requestFilters.label_ids = filters.labelIds; } + if (filters.creditorName) { + requestFilters.creditor_name = filters.creditorName; + } + if (filters.debtorName) { + requestFilters.debtor_name = filters.debtorName; + } const response = await axios.patch('/transactions/bulk', { filters: requestFilters, diff --git a/resources/js/types/import.ts b/resources/js/types/import.ts index e69d35a2..c309b8eb 100644 --- a/resources/js/types/import.ts +++ b/resources/js/types/import.ts @@ -18,6 +18,8 @@ export interface ColumnMapping { description: string | string[] | null; amount: string | null; balance: string | null; + creditor_name: string | null; + debtor_name: string | null; } export interface ParsedRow { @@ -29,6 +31,8 @@ export interface ParsedTransaction { description: string; amount: number; balance?: number | null; + creditor_name?: string | null; + debtor_name?: string | null; isDuplicate?: boolean; selected?: boolean; validationErrors?: string[]; diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 4873770b..1520124f 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -17,6 +17,8 @@ export interface Transaction { currency_code: string; notes: string | null; notes_iv: string | null; + creditor_name?: string | null; + debtor_name?: string | null; source: TransactionSource; label_ids?: UUID[]; created_at: string; @@ -46,5 +48,7 @@ export interface TransactionFilters { categoryIds: UUID[]; accountIds: UUID[]; labelIds: UUID[]; + creditorName: string; + debtorName: string; searchText: string; } diff --git a/tests/Feature/AutomationRuleApplicationTest.php b/tests/Feature/AutomationRuleApplicationTest.php index 80514ed4..47c62d94 100644 --- a/tests/Feature/AutomationRuleApplicationTest.php +++ b/tests/Feature/AutomationRuleApplicationTest.php @@ -60,6 +60,37 @@ test('matches endpoint returns transactions matching the rule', function () { ->assertJsonCount(1, 'data'); }); +test('matches endpoint returns transactions matching creditor name rule', function () { + $this->rule->update([ + 'rules_json' => ['in' => ['amazon', ['var' => 'creditor_name']]], + ]); + + Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'category_id' => null, + 'description' => 'Card payment', + 'creditor_name' => 'Amazon EU', + 'amount' => -1000, + ]); + + Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'category_id' => null, + 'description' => 'Card payment', + 'creditor_name' => 'Coffee Shop', + 'amount' => -500, + ]); + + $response = $this->actingAs($this->user) + ->getJson(route('automation-rules.matches', $this->rule)); + + $response->assertOk() + ->assertJsonPath('total', 1) + ->assertJsonCount(1, 'data'); +}); + test('matches endpoint skips already categorized when only_uncategorized is true', function () { Transaction::factory()->enableBanking()->create([ 'user_id' => $this->user->id, diff --git a/tests/Feature/AutomationRuleEvaluationTest.php b/tests/Feature/AutomationRuleEvaluationTest.php index 9af83ec7..b4a3d2dc 100644 --- a/tests/Feature/AutomationRuleEvaluationTest.php +++ b/tests/Feature/AutomationRuleEvaluationTest.php @@ -44,6 +44,48 @@ test('assigns category when "in" operator matches description', function () { expect($transaction->fresh()->category_id)->toBe($this->category->id); }); +test('assigns category when rule matches creditor name', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['amazon', ['var' => 'creditor_name']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Card payment', + 'creditor_name' => 'Amazon EU', + 'amount' => -5000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('assigns category when rule matches debtor name', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['payroll', ['var' => 'debtor_name']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Incoming transfer', + 'debtor_name' => 'Payroll GmbH', + 'amount' => 500000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + test('assigns labels when rule matches', function () { $label = Label::factory()->create(['user_id' => $this->user->id]); diff --git a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php index 9d7b28d2..48d138ad 100644 --- a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php +++ b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php @@ -162,6 +162,41 @@ test('sync handles pagination with continuation key', function () { expect($account->transactions()->count())->toBe(2); }); +test('sync stores creditor and debtor names from raw payload', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getTransactions') + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '99.99', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => ['Card payment'], + 'creditor' => ['name' => 'Amazon EU'], + 'debtor' => ['name' => 'Victor Falcon'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter); + $service->sync($account, '2025-01-01', '2025-01-31'); + + $transaction = $account->transactions()->first(); + expect($transaction->creditor_name)->toBe('Amazon EU') + ->and($transaction->debtor_name)->toBe('Victor Falcon'); +}); + test('sync uses creditor name as fallback description', function () { $user = User::factory()->onboarded()->create(); $connection = BankingConnection::factory()->create(['user_id' => $user->id]); diff --git a/tests/Feature/TransactionFilterTest.php b/tests/Feature/TransactionFilterTest.php index 73523a2c..d3bf6c16 100644 --- a/tests/Feature/TransactionFilterTest.php +++ b/tests/Feature/TransactionFilterTest.php @@ -214,6 +214,79 @@ test('search matches description', function () { ); }); +test('filter by creditor name', function () { + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'creditor_name' => 'Amazon EU', + ]); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'creditor_name' => 'Coffee Shop', + ]); + + $response = actingAs($this->user)->get(route('transactions.index', [ + 'creditor_name' => 'Amazon', + ])); + + $response->assertInertia(fn ($page) => $page + ->has('transactions.data', 1) + ->where('transactions.data.0.creditor_name', 'Amazon EU') + ->where('appliedFilters.creditor_name', 'Amazon') + ); +}); + +test('filter by debtor name', function () { + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'debtor_name' => 'Payroll GmbH', + ]); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'debtor_name' => 'Other Sender', + ]); + + $response = actingAs($this->user)->get(route('transactions.index', [ + 'debtor_name' => 'Payroll', + ])); + + $response->assertInertia(fn ($page) => $page + ->has('transactions.data', 1) + ->where('transactions.data.0.debtor_name', 'Payroll GmbH') + ->where('appliedFilters.debtor_name', 'Payroll') + ); +}); + +test('search matches counterparty names', function () { + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Card payment', + 'creditor_name' => 'Amazon EU', + ]); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Card payment', + 'creditor_name' => 'Coffee Shop', + ]); + + $response = actingAs($this->user)->get(route('transactions.index', [ + 'search' => 'Amazon', + ])); + + $response->assertInertia(fn ($page) => $page + ->has('transactions.data', 1) + ->where('transactions.data.0.creditor_name', 'Amazon EU') + ); +}); + test('search matches notes', function () { Transaction::factory()->plaintext()->create([ 'user_id' => $this->user->id, diff --git a/tests/Unit/Services/Banking/TransactionCounterpartyExtractorTest.php b/tests/Unit/Services/Banking/TransactionCounterpartyExtractorTest.php new file mode 100644 index 00000000..719a9077 --- /dev/null +++ b/tests/Unit/Services/Banking/TransactionCounterpartyExtractorTest.php @@ -0,0 +1,23 @@ + ['name' => 'VICTOR;FALCON;RUIZ'], + 'debtor' => ['name' => 'ACME;;PAYROLL'], + ]); + + expect($counterparties['creditor_name'])->toBe('VICTOR FALCON RUIZ') + ->and($counterparties['debtor_name'])->toBe('ACME PAYROLL'); +}); + +it('ignores masked counterparty names', function () { + $counterparties = TransactionCounterpartyExtractor::fromPayload([ + 'creditor' => ['name' => '*****'], + 'debtor' => ['name' => ' ; * ; * ; '], + ]); + + expect($counterparties['creditor_name'])->toBeNull() + ->and($counterparties['debtor_name'])->toBeNull(); +});