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:
parent
6caadad1db
commit
10da06ed84
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.*' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
11
lang/es.json
11
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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue