feat: Add per-bank description formatter for bank-synced transactions (#120)
## Summary - Adds a `BankFormatter` interface with a `BbvaFormatter` implementation that transforms ALL CAPS `//`-separated BBVA descriptions into readable Title Case (preserving acronyms like SEPA, S.A., BIZUM and Spanish stopwords) - Stores the raw description in a new `original_description` column when formatting changes it, keeping sync matching intact - Existing non-BBVA banks and null-bank transactions pass through unchanged ## Test plan - [x] Unit tests for `BbvaFormatter` (11 tests: title case, acronyms, stopwords, reference numbers, mixed-case passthrough, whitespace) - [x] Unit tests for `TransactionDescriptionFormatter` dispatcher (4 tests: BBVA match, no-match, null bank) - [x] Feature tests for BBVA sync integration (formatted description + original stored) - [x] Feature tests for non-BBVA sync (description unchanged, original null) - [x] All 25 existing + new tests pass - [x] Pint formatting clean
This commit is contained in:
parent
79164345d9
commit
9242b3fe5f
|
|
@ -32,6 +32,7 @@ class Transaction extends Model
|
|||
'category_id',
|
||||
'description',
|
||||
'description_iv',
|
||||
'original_description',
|
||||
'transaction_date',
|
||||
'amount',
|
||||
'currency_code',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking\Formatters;
|
||||
|
||||
interface BankFormatter
|
||||
{
|
||||
public function matches(string $bankName): bool;
|
||||
|
||||
public function format(string $description): string;
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking\Formatters;
|
||||
|
||||
class BbvaFormatter implements BankFormatter
|
||||
{
|
||||
/** @var string[] */
|
||||
private const ACRONYMS = [
|
||||
'SEPA', 'IBAN', 'CPVR', 'BIZUM', 'ATM', 'BBVA',
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
private const PRESERVED_PATTERNS = [
|
||||
'S.A.', 'S.L.', 'S.L.U.',
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
private const STOPWORDS = [
|
||||
'de', 'del', 'la', 'las', 'los', 'el', 'en', 'y', 'a', 'al', 'por', 'con', 'para',
|
||||
];
|
||||
|
||||
public function matches(string $bankName): bool
|
||||
{
|
||||
return mb_strtolower($bankName) === 'bbva';
|
||||
}
|
||||
|
||||
public function format(string $description): string
|
||||
{
|
||||
if (! $this->isMostlyUppercase($description)) {
|
||||
return $description;
|
||||
}
|
||||
|
||||
$description = str_replace('//', '/', $description);
|
||||
$description = preg_replace('/\s+/', ' ', $description);
|
||||
|
||||
$segments = explode('/', $description);
|
||||
$formatted = array_map(fn (string $segment) => $this->formatSegment(trim($segment)), $segments);
|
||||
|
||||
return implode(' / ', array_filter($formatted, fn (string $s) => $s !== ''));
|
||||
}
|
||||
|
||||
private function isMostlyUppercase(string $text): bool
|
||||
{
|
||||
$letters = preg_replace('/[^a-zA-Z]/', '', $text);
|
||||
|
||||
if ($letters === '' || $letters === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$uppercase = preg_replace('/[^A-Z]/', '', $letters);
|
||||
|
||||
return mb_strlen($uppercase) / mb_strlen($letters) > 0.7;
|
||||
}
|
||||
|
||||
private function formatSegment(string $segment): string
|
||||
{
|
||||
if ($segment === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$words = preg_split('/\s+/', $segment);
|
||||
$formatted = [];
|
||||
|
||||
foreach ($words as $index => $word) {
|
||||
$formatted[] = $this->formatWord($word, $index === 0);
|
||||
}
|
||||
|
||||
return implode(' ', $formatted);
|
||||
}
|
||||
|
||||
private function formatWord(string $word, bool $isFirst): string
|
||||
{
|
||||
if ($this->isReferenceNumber($word)) {
|
||||
return $word;
|
||||
}
|
||||
|
||||
$upperWord = mb_strtoupper($word);
|
||||
|
||||
foreach (self::PRESERVED_PATTERNS as $pattern) {
|
||||
if ($upperWord === str_replace('.', '', $pattern) || $upperWord === $pattern) {
|
||||
return $pattern;
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($upperWord, self::ACRONYMS, true)) {
|
||||
return $upperWord;
|
||||
}
|
||||
|
||||
$lower = mb_strtolower($word);
|
||||
|
||||
if (! $isFirst && in_array($lower, self::STOPWORDS, true)) {
|
||||
return $lower;
|
||||
}
|
||||
|
||||
return mb_strtoupper(mb_substr($lower, 0, 1)).mb_substr($lower, 1);
|
||||
}
|
||||
|
||||
private function isReferenceNumber(string $word): bool
|
||||
{
|
||||
return (bool) preg_match('/\d/', $word);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking;
|
||||
|
||||
use App\Services\Banking\Formatters\BankFormatter;
|
||||
use App\Services\Banking\Formatters\BbvaFormatter;
|
||||
|
||||
class TransactionDescriptionFormatter
|
||||
{
|
||||
/** @var BankFormatter[] */
|
||||
private array $formatters;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->formatters = [
|
||||
new BbvaFormatter,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{description: string, original_description: string|null}
|
||||
*/
|
||||
public function format(string $description, ?string $bankName): array
|
||||
{
|
||||
if ($bankName) {
|
||||
foreach ($this->formatters as $formatter) {
|
||||
if ($formatter->matches($bankName)) {
|
||||
$formatted = $formatter->format($description);
|
||||
|
||||
return [
|
||||
'description' => $formatted,
|
||||
'original_description' => $formatted !== $description ? $description : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['description' => $description, 'original_description' => null];
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ class TransactionSyncService
|
|||
{
|
||||
public function __construct(
|
||||
private BankingProviderInterface $provider,
|
||||
private TransactionDescriptionFormatter $descriptionFormatter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -27,6 +28,7 @@ class TransactionSyncService
|
|||
$created = 0;
|
||||
$continuationKey = null;
|
||||
$dailyBalances = [];
|
||||
$bankName = $account->bank?->name;
|
||||
|
||||
do {
|
||||
$result = $this->provider->getTransactions(
|
||||
|
|
@ -38,7 +40,7 @@ class TransactionSyncService
|
|||
);
|
||||
|
||||
foreach ($result['transactions'] as $transaction) {
|
||||
if ($this->importTransaction($account, $transaction)) {
|
||||
if ($this->importTransaction($account, $transaction, $bankName)) {
|
||||
$created++;
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +69,7 @@ class TransactionSyncService
|
|||
/**
|
||||
* Import a single transaction, skipping duplicates.
|
||||
*/
|
||||
private function importTransaction(Account $account, array $data): bool
|
||||
private function importTransaction(Account $account, array $data, ?string $bankName): bool
|
||||
{
|
||||
$externalId = $data['transaction_id'] ?? $data['entry_reference'] ?? null;
|
||||
|
||||
|
|
@ -83,14 +85,16 @@ class TransactionSyncService
|
|||
}
|
||||
|
||||
$amount = $this->parseAmount($data);
|
||||
$description = $this->parseDescription($data);
|
||||
$rawDescription = $this->parseDescription($data);
|
||||
$formatted = $this->descriptionFormatter->format($rawDescription, $bankName);
|
||||
$transactionDate = $this->parseDate($data);
|
||||
$currency = $data['transaction_amount']['currency'] ?? $account->currency_code;
|
||||
|
||||
$account->transactions()->create([
|
||||
'user_id' => $account->user_id,
|
||||
'description' => $description,
|
||||
'description' => $formatted['description'],
|
||||
'description_iv' => null,
|
||||
'original_description' => $formatted['original_description'],
|
||||
'transaction_date' => $transactionDate,
|
||||
'amount' => $amount,
|
||||
'currency_code' => $currency,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->text('original_description')->nullable()->after('description_iv');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->dropColumn('original_description');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Banking\TransactionDescriptionFormatter;
|
||||
use App\Services\Banking\TransactionSyncService;
|
||||
|
||||
test('sync creates transactions from provider data', function () {
|
||||
|
|
@ -41,7 +43,7 @@ test('sync creates transactions from provider data', function () {
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$created = $service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($created)->toBe(2);
|
||||
|
|
@ -103,7 +105,7 @@ test('sync deduplicates transactions by external_transaction_id', function () {
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$created = $service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($created)->toBe(1);
|
||||
|
|
@ -153,7 +155,7 @@ test('sync handles pagination with continuation key', function () {
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$created = $service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($created)->toBe(2);
|
||||
|
|
@ -186,7 +188,7 @@ test('sync uses creditor name as fallback description', function () {
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
$transaction = $account->transactions()->first();
|
||||
|
|
@ -235,7 +237,7 @@ test('sync creates daily balances from balance_after_transaction', function () {
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($account->balances()->count())->toBe(2);
|
||||
|
|
@ -272,7 +274,7 @@ test('sync skips daily balance when balance_after_transaction is missing', funct
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($account->balances()->count())->toBe(0);
|
||||
|
|
@ -310,7 +312,7 @@ test('sync does not re-create soft-deleted transactions', function () {
|
|||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$created = $service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($created)->toBe(0);
|
||||
|
|
@ -328,8 +330,78 @@ test('sync skips accounts without external_account_id', function () {
|
|||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('getTransactions');
|
||||
|
||||
$service = new TransactionSyncService($mockProvider);
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$created = $service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
expect($created)->toBe(0);
|
||||
});
|
||||
|
||||
test('sync formats BBVA transaction descriptions and stores original', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'BBVA', 'user_id' => $user->id]);
|
||||
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||
$account = Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->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' => '50.00', 'currency' => 'EUR'],
|
||||
'credit_debit_indicator' => 'DBIT',
|
||||
'booking_date' => '2025-01-15',
|
||||
'remittance_information' => ['ADEUDO DE ENDESA // PAGO DE ADEUDO DIRECTO SEPA'],
|
||||
],
|
||||
],
|
||||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
$transaction = $account->transactions()->first();
|
||||
expect($transaction->description)->toBe('Adeudo de Endesa / Pago de Adeudo Directo SEPA');
|
||||
expect($transaction->original_description)->toBe('ADEUDO DE ENDESA // PAGO DE ADEUDO DIRECTO SEPA');
|
||||
});
|
||||
|
||||
test('sync does not format descriptions for non-BBVA banks', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'ING', 'user_id' => $user->id]);
|
||||
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||
$account = Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->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' => '50.00', 'currency' => 'EUR'],
|
||||
'credit_debit_indicator' => 'DBIT',
|
||||
'booking_date' => '2025-01-15',
|
||||
'remittance_information' => ['ADEUDO DE ENDESA'],
|
||||
],
|
||||
],
|
||||
'continuation_key' => null,
|
||||
]);
|
||||
|
||||
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
|
||||
$service->sync($account, '2025-01-01', '2025-01-31');
|
||||
|
||||
$transaction = $account->transactions()->first();
|
||||
expect($transaction->description)->toBe('ADEUDO DE ENDESA');
|
||||
expect($transaction->original_description)->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Banking\Formatters\BbvaFormatter;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->formatter = new BbvaFormatter;
|
||||
});
|
||||
|
||||
test('matches BBVA bank name case-insensitively', function () {
|
||||
expect($this->formatter->matches('BBVA'))->toBeTrue();
|
||||
expect($this->formatter->matches('bbva'))->toBeTrue();
|
||||
expect($this->formatter->matches('Bbva'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('does not match other banks', function () {
|
||||
expect($this->formatter->matches('ING'))->toBeFalse();
|
||||
expect($this->formatter->matches('Santander'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('formats ALL CAPS description with // separators to Title Case', function () {
|
||||
$input = 'ADEUDO DE ENDESA // PAGO DE ADEUDO DIRECTO SEPA // N 2026041001476680 ENDESA ENERGIA S.A. CPVR';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toBe('Adeudo de Endesa / Pago de Adeudo Directo SEPA / N 2026041001476680 Endesa Energia S.A. CPVR');
|
||||
});
|
||||
|
||||
test('preserves acronyms in uppercase', function () {
|
||||
$input = 'TRANSFERENCIA SEPA BIZUM ATM';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toContain('SEPA');
|
||||
expect($result)->toContain('BIZUM');
|
||||
expect($result)->toContain('ATM');
|
||||
});
|
||||
|
||||
test('preserves S.A. and S.L. patterns', function () {
|
||||
$input = 'PAGO A ENDESA ENERGIA SA';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toContain('S.A.');
|
||||
});
|
||||
|
||||
test('lowercases Spanish stopwords except at segment start', function () {
|
||||
$input = 'PAGO DE LA FACTURA // DE LOS SERVICIOS';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toBe('Pago de la Factura / De los Servicios');
|
||||
});
|
||||
|
||||
test('capitalizes first word of each segment', function () {
|
||||
$input = 'DE LA CUENTA // EN EL BANCO';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toStartWith('De la');
|
||||
expect($result)->toContain('/ En el');
|
||||
});
|
||||
|
||||
test('preserves reference numbers as-is', function () {
|
||||
$input = 'TRANSFERENCIA N 2026041001476680 PARA EMPRESA';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toContain('N 2026041001476680');
|
||||
});
|
||||
|
||||
test('collapses multiple whitespace', function () {
|
||||
$input = 'PAGO DE SERVICIOS';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->not->toContain(' ');
|
||||
});
|
||||
|
||||
test('does not transform mixed-case descriptions', function () {
|
||||
$input = 'Grocery Store Purchase';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toBe('Grocery Store Purchase');
|
||||
});
|
||||
|
||||
test('handles empty segments from double slashes', function () {
|
||||
$input = 'CONCEPTO // // DETALLE';
|
||||
$result = $this->formatter->format($input);
|
||||
|
||||
expect($result)->toBe('Concepto / Detalle');
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Banking\TransactionDescriptionFormatter;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->formatter = new TransactionDescriptionFormatter;
|
||||
});
|
||||
|
||||
test('formats description for BBVA bank and stores original', function () {
|
||||
$result = $this->formatter->format('ADEUDO DE ENDESA // PAGO SEPA', 'BBVA');
|
||||
|
||||
expect($result['description'])->toBe('Adeudo de Endesa / Pago SEPA');
|
||||
expect($result['original_description'])->toBe('ADEUDO DE ENDESA // PAGO SEPA');
|
||||
});
|
||||
|
||||
test('does not set original_description when formatting produces no change', function () {
|
||||
$result = $this->formatter->format('Already Nice Description', 'BBVA');
|
||||
|
||||
expect($result['description'])->toBe('Already Nice Description');
|
||||
expect($result['original_description'])->toBeNull();
|
||||
});
|
||||
|
||||
test('does not transform description for non-BBVA bank', function () {
|
||||
$result = $this->formatter->format('ADEUDO DE ENDESA', 'ING');
|
||||
|
||||
expect($result['description'])->toBe('ADEUDO DE ENDESA');
|
||||
expect($result['original_description'])->toBeNull();
|
||||
});
|
||||
|
||||
test('does not transform description when bank name is null', function () {
|
||||
$result = $this->formatter->format('ADEUDO DE ENDESA', null);
|
||||
|
||||
expect($result['description'])->toBe('ADEUDO DE ENDESA');
|
||||
expect($result['original_description'])->toBeNull();
|
||||
});
|
||||
Loading…
Reference in New Issue