diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 8c6bd639..a9101890 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -32,6 +32,7 @@ class Transaction extends Model 'category_id', 'description', 'description_iv', + 'original_description', 'transaction_date', 'amount', 'currency_code', diff --git a/app/Services/Banking/Formatters/BankFormatter.php b/app/Services/Banking/Formatters/BankFormatter.php new file mode 100644 index 00000000..11b1a450 --- /dev/null +++ b/app/Services/Banking/Formatters/BankFormatter.php @@ -0,0 +1,10 @@ +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); + } +} diff --git a/app/Services/Banking/TransactionDescriptionFormatter.php b/app/Services/Banking/TransactionDescriptionFormatter.php new file mode 100644 index 00000000..29d6758a --- /dev/null +++ b/app/Services/Banking/TransactionDescriptionFormatter.php @@ -0,0 +1,40 @@ +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]; + } +} diff --git a/app/Services/Banking/TransactionSyncService.php b/app/Services/Banking/TransactionSyncService.php index 3aacf4e2..20fe4ea6 100644 --- a/app/Services/Banking/TransactionSyncService.php +++ b/app/Services/Banking/TransactionSyncService.php @@ -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, diff --git a/database/migrations/2026_02_13_083716_add_original_description_to_transactions_table.php b/database/migrations/2026_02_13_083716_add_original_description_to_transactions_table.php new file mode 100644 index 00000000..1c2faa69 --- /dev/null +++ b/database/migrations/2026_02_13_083716_add_original_description_to_transactions_table.php @@ -0,0 +1,28 @@ +text('original_description')->nullable()->after('description_iv'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropColumn('original_description'); + }); + } +}; diff --git a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php index 6984266d..412824ed 100644 --- a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php +++ b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php @@ -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(); +}); diff --git a/tests/Unit/Services/Banking/Formatters/BbvaFormatterTest.php b/tests/Unit/Services/Banking/Formatters/BbvaFormatterTest.php new file mode 100644 index 00000000..437fd2c2 --- /dev/null +++ b/tests/Unit/Services/Banking/Formatters/BbvaFormatterTest.php @@ -0,0 +1,84 @@ +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'); +}); diff --git a/tests/Unit/Services/Banking/TransactionDescriptionFormatterTest.php b/tests/Unit/Services/Banking/TransactionDescriptionFormatterTest.php new file mode 100644 index 00000000..2ba6b923 --- /dev/null +++ b/tests/Unit/Services/Banking/TransactionDescriptionFormatterTest.php @@ -0,0 +1,35 @@ +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(); +});