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:
Víctor Falcón 2026-02-13 09:54:58 +01:00 committed by GitHub
parent 79164345d9
commit 9242b3fe5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 388 additions and 12 deletions

View File

@ -32,6 +32,7 @@ class Transaction extends Model
'category_id',
'description',
'description_iv',
'original_description',
'transaction_date',
'amount',
'currency_code',

View File

@ -0,0 +1,10 @@
<?php
namespace App\Services\Banking\Formatters;
interface BankFormatter
{
public function matches(string $bankName): bool;
public function format(string $description): string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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