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) && (
+
+ )}
+
{
@@ -422,10 +418,7 @@ export function ImportStepMapping({
+
+
+
+
+
+
+
+
+
+
+
+
{baseMappingValid && previewTransactions.length > 0 && (
+
+
{__('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();
+});