feat(transactions): add counterparty fields (#440)

## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns

## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent

Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.

## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
This commit is contained in:
Víctor Falcón 2026-05-27 16:20:55 +02:00 committed by GitHub
parent 6caadad1db
commit 10da06ed84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 980 additions and 47 deletions

View File

@ -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,
];

View File

@ -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'],
];
}

View File

@ -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',

View File

@ -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'],
];

View File

@ -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.*' => [

View File

@ -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',

View File

@ -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;

View File

@ -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,
];
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Services\Banking;
class TransactionCounterpartyExtractor
{
/**
* @param array<string, mixed> $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);
}
}

View File

@ -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

View File

@ -0,0 +1,57 @@
<?php
use App\Services\Banking\TransactionCounterpartyExtractor;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -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",

View File

@ -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: () => <div />,
}));
vi.mock('@/components/transactions/category-select', () => ({
CategorySelect: () => <div />,
}));
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 ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
}));
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(
<EditTransactionDialog
transaction={{
id: 'tx-1',
user_id: 'user-1',
account_id: 'account-1',
category_id: null,
description: 'Card payment',
description_iv: null,
transaction_date: '2026-05-27',
amount: -1200,
currency_code: 'EUR',
notes: null,
notes_iv: null,
creditor_name: 'Amazon EU',
debtor_name: 'Victor Falcon',
source: 'imported',
created_at: '2026-05-27T00:00:00Z',
updated_at: '2026-05-27T00:00:00Z',
decryptedDescription: 'Card payment',
decryptedNotes: null,
label_ids: [],
}}
categories={[]}
accounts={[]}
banks={[]}
labels={[]}
open
onOpenChange={vi.fn()}
onSuccess={vi.fn()}
mode="edit"
/>,
);
expect(screen.getByText('Creditor')).toBeInTheDocument();
expect(screen.getByDisplayValue('Amazon EU')).toBeDisabled();
expect(screen.getByText('Debtor')).toBeInTheDocument();
expect(screen.getByDisplayValue('Victor Falcon')).toBeDisabled();
});
});

View File

@ -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({
)}
</div>
{mode === 'edit' &&
(transaction?.creditor_name ||
transaction?.debtor_name) && (
<div className="grid gap-4 md:grid-cols-2">
{transaction.creditor_name && (
<div className="space-y-2">
<FormLabel className="text-sm text-muted-foreground">
{__('Creditor')}
</FormLabel>
<Input
value={
transaction.creditor_name
}
disabled
readOnly
className="bg-muted"
/>
</div>
)}
{transaction.debtor_name && (
<div className="space-y-2">
<FormLabel className="text-sm text-muted-foreground">
{__('Debtor')}
</FormLabel>
<Input
value={transaction.debtor_name}
disabled
readOnly
className="bg-muted"
/>
</div>
)}
</div>
)}
<div className="space-y-2">
<FormLabel
htmlFor="amount"

View File

@ -83,11 +83,7 @@ export function ImportStepMapping({
if (!effectiveCalculate) {
return null;
}
return getLatestTransactionDate(
parsedData,
columnMapping,
dateFormat,
);
return getLatestTransactionDate(parsedData, columnMapping, dateFormat);
}, [effectiveCalculate, parsedData, columnMapping, dateFormat]);
useEffect(() => {
@ -422,10 +418,7 @@ export function ImportStepMapping({
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<Label htmlFor="reference-balance">
{__('Balance on')}{' '}
{formatRelativeDate(
latestDate,
locale,
)}{' '}
{formatRelativeDate(latestDate, locale)}{' '}
<span className="text-destructive">
*
</span>
@ -450,6 +443,80 @@ export function ImportStepMapping({
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="creditor-column">
{__('Creditor name (Optional)')}
</Label>
<Select
value={columnMapping.creditor_name || '__none__'}
onValueChange={(value) =>
onMappingChange(
'creditor_name',
value === '__none__' ? '' : value,
)
}
>
<SelectTrigger id="creditor-column">
<SelectValue
placeholder={__(
'Select creditor column (optional)',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{__('None')}
</SelectItem>
{columnOptions.map((option, index) => (
<SelectItem
key={`creditor-${option.value}-${index}`}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="debtor-column">
{__('Debtor name (Optional)')}
</Label>
<Select
value={columnMapping.debtor_name || '__none__'}
onValueChange={(value) =>
onMappingChange(
'debtor_name',
value === '__none__' ? '' : value,
)
}
>
<SelectTrigger id="debtor-column">
<SelectValue
placeholder={__(
'Select debtor column (optional)',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{__('None')}
</SelectItem>
{columnOptions.map((option, index) => (
<SelectItem
key={`debtor-${option.value}-${index}`}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{baseMappingValid && previewTransactions.length > 0 && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">

View File

@ -99,6 +99,8 @@ export function ImportTransactionsDrawer({
description: null,
amount: null,
balance: null,
creditor_name: null,
debtor_name: null,
},
dateFormat: DateFormat.YearMonthDay,
dateFormatDetected: false,
@ -134,6 +136,8 @@ export function ImportTransactionsDrawer({
description: null,
amount: null,
balance: null,
creditor_name: null,
debtor_name: null,
},
dateFormat: DateFormat.YearMonthDay,
dateFormatDetected: false,
@ -306,22 +310,19 @@ export function ImportTransactionsDrawer({
}));
};
const handleLatestDateChange = useCallback(
(date: string | null) => {
setState((prev) => {
if (prev.referenceBalanceDate === date) {
return prev;
}
return {
...prev,
referenceBalanceDate: date,
referenceBalance: null,
referenceBalancePrefilled: false,
};
});
},
[],
);
const handleLatestDateChange = useCallback((date: string | null) => {
setState((prev) => {
if (prev.referenceBalanceDate === date) {
return prev;
}
return {
...prev,
referenceBalanceDate: date,
referenceBalance: null,
referenceBalancePrefilled: false,
};
});
}, []);
// Try to pre-fill the reference balance from an existing balance record
// on that date. If found, no need to ask the user.
@ -446,7 +447,9 @@ export function ImportTransactionsDrawer({
balance:
calculatedBalances.get(
transaction.transaction_date,
) ?? transaction.balance ?? null,
) ??
transaction.balance ??
null,
}));
}
@ -519,6 +522,8 @@ export function ImportTransactionsDrawer({
amount: transaction.amount / 100,
transaction_date: transaction.transaction_date,
account_id: selectedAccount.id,
creditor_name: transaction.creditor_name,
debtor_name: transaction.debtor_name,
},
rules,
categories,
@ -552,17 +557,20 @@ export function ImportTransactionsDrawer({
const transactionData = {
user_id:
(selectedAccount as Account & { user_id?: number })
.user_id || 0,
(selectedAccount as Account & { user_id?: string })
.user_id ||
'00000000-0000-0000-0000-000000000000',
account_id: selectedAccount.id,
category_id: categoryId,
description: encrypted,
description_iv: iv,
transaction_date: transaction.transaction_date,
amount: transaction.amount.toString(),
amount: transaction.amount,
currency_code: selectedAccount.currency_code,
notes: notes,
notes_iv: notesIv,
creditor_name: transaction.creditor_name ?? null,
debtor_name: transaction.debtor_name ?? null,
source: 'imported' as const,
label_ids: labelIds.length > 0 ? labelIds : undefined,
};

View File

@ -251,6 +251,78 @@ export function createTransactionColumns({
},
enableHiding: false,
},
{
accessorKey: 'creditor_name',
meta: {
label: __('Creditor'),
cellClassName: 'max-w-[180px] whitespace-normal',
},
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
{__('Creditor')}
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="h-3 w-3" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-25" />
)}
</Button>
);
},
cell: ({ row }) => {
const creditorName = row.original.creditor_name;
return creditorName ? (
<div className="truncate">{creditorName}</div>
) : (
<div className="text-muted-foreground"></div>
);
},
enableHiding: true,
},
{
accessorKey: 'debtor_name',
meta: {
label: __('Debtor'),
cellClassName: 'max-w-[180px] whitespace-normal',
},
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
{__('Debtor')}
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="h-3 w-3" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-25" />
)}
</Button>
);
},
cell: ({ row }) => {
const debtorName = row.original.debtor_name;
return debtorName ? (
<div className="truncate">{debtorName}</div>
) : (
<div className="text-muted-foreground"></div>
);
},
enableHiding: true,
},
{
accessorKey: 'amount',
meta: { label: __('Amount') },

View File

@ -52,32 +52,46 @@ export function TransactionFilters({
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const [searchText, setSearchText] = useState(filters.searchText);
const [creditorName, setCreditorName] = useState(filters.creditorName);
const [debtorName, setDebtorName] = useState(filters.debtorName);
const isUncategorizedSelected = filters.categoryIds.includes(
UNCATEGORIZED_CATEGORY_ID,
);
// Debounce search text updates
// Debounce text filter updates
useEffect(() => {
const timer = setTimeout(() => {
if (searchText !== filters.searchText) {
if (
searchText !== filters.searchText ||
creditorName !== filters.creditorName ||
debtorName !== filters.debtorName
) {
onFiltersChange({
...filters,
searchText,
creditorName,
debtorName,
});
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText]);
}, [searchText, creditorName, debtorName]);
// Sync local state when filters change externally
useEffect(() => {
if (filters.searchText !== searchText) {
setSearchText(filters.searchText);
}
if (filters.creditorName !== creditorName) {
setCreditorName(filters.creditorName);
}
if (filters.debtorName !== debtorName) {
setDebtorName(filters.debtorName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchText]);
}, [filters.searchText, filters.creditorName, filters.debtorName]);
function handleCategoryToggle(categoryId: string) {
const newCategoryIds = filters.categoryIds.includes(categoryId)
@ -105,6 +119,8 @@ export function TransactionFilters({
function clearFilters() {
setSearchText('');
setCreditorName('');
setDebtorName('');
onFiltersChange({
dateFrom: null,
dateTo: null,
@ -113,6 +129,8 @@ export function TransactionFilters({
categoryIds: [],
accountIds: [],
labelIds: [],
creditorName: '',
debtorName: '',
searchText: '',
});
}
@ -124,6 +142,8 @@ export function TransactionFilters({
(filters.amountMax !== null ? 1 : 0) +
filters.categoryIds.length +
filters.labelIds.length +
(filters.creditorName ? 1 : 0) +
(filters.debtorName ? 1 : 0) +
(hideAccountFilter ? 0 : filters.accountIds.length);
return (
@ -253,6 +273,28 @@ export function TransactionFilters({
</div>
</div>
<div className="space-y-2">
<FormLabel>
{__('Counterparties')}
</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
value={creditorName}
onChange={(e) =>
setCreditorName(e.target.value)
}
placeholder={__('Creditor name')}
/>
<Input
value={debtorName}
onChange={(e) =>
setDebtorName(e.target.value)
}
placeholder={__('Debtor name')}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>{__('Categories')}</FormLabel>
<div className="pt-2">

View File

@ -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) {

View File

@ -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',

View File

@ -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: [],
});
}

View File

@ -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 = {

View File

@ -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',

View File

@ -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(

View File

@ -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,

View File

@ -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,

View File

@ -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[];

View File

@ -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;
}

View File

@ -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,

View File

@ -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]);

View File

@ -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]);

View File

@ -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,

View File

@ -0,0 +1,23 @@
<?php
use App\Services\Banking\TransactionCounterpartyExtractor;
it('normalizes semicolon separated counterparty names', function () {
$counterparties = TransactionCounterpartyExtractor::fromPayload([
'creditor' => ['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();
});