From a31985822cbd9233e2be3e91066ddb44a98402a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Fri, 3 Jul 2026 16:05:36 +0200 Subject: [PATCH] fix(import): match Unicode whitespace in server-side duplicate detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP's default \s is ASCII-only, so the duplicate check missed rows differing only by a non-breaking space (U+00A0) or other Unicode whitespace — common in bank statement descriptions — whereas the old client-side check (JS \s) caught them. Normalize the full Unicode whitespace set before keying, then trim. Also harden the endpoint's tests: Unicode-whitespace matching, the date-range boundary (an out-of-range existing row must not match), and request validation. --- .../Controllers/Api/TransactionController.php | 11 +++- .../Api/TransactionDuplicateCheckTest.php | 54 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/TransactionController.php b/app/Http/Controllers/Api/TransactionController.php index 4359a598..9e598876 100644 --- a/app/Http/Controllers/Api/TransactionController.php +++ b/app/Http/Controllers/Api/TransactionController.php @@ -80,7 +80,16 @@ class TransactionController extends Controller private function duplicateKey(string $date, int $amount, string $description): string { - $normalized = preg_replace('/\s+/', ' ', trim(mb_strtolower($description))); + // Collapse every Unicode whitespace run (matching JS \s, which includes + // the non-breaking spaces common in bank statements) to a single space, + // then trim. PHP's default \s is ASCII-only, so without this an existing + // "Coffee Shop" and an imported "Coffee Shop" would not be seen as + // the same row, unlike the old client-side check. + $normalized = trim((string) preg_replace( + '/[\s\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]+/u', + ' ', + mb_strtolower($description), + )); return $date.'|'.$amount.'|'.$normalized; } diff --git a/tests/Feature/Api/TransactionDuplicateCheckTest.php b/tests/Feature/Api/TransactionDuplicateCheckTest.php index c0ebb6ba..d383027c 100644 --- a/tests/Feature/Api/TransactionDuplicateCheckTest.php +++ b/tests/Feature/Api/TransactionDuplicateCheckTest.php @@ -85,3 +85,57 @@ it('does not leak another users account', function () { $response->assertNotFound(); }); + +it('treats non-breaking spaces as regular whitespace when matching', function () { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'transaction_date' => '2026-01-15', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => "Coffee\u{00A0}Shop"], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [true]]); +}); + +it('only matches existing transactions within the incoming date range', function () { + // Same amount + description as the incoming row, but outside its date range. + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'transaction_date' => '2025-12-01', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => 'Coffee Shop'], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [false]]); +}); + +it('validates the request payload', function () { + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'transactions' => [ + ['transaction_date' => 'not-a-date', 'amount' => 'abc', 'description' => ''], + ], + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors([ + 'account_id', + 'transactions.0.transaction_date', + 'transactions.0.amount', + 'transactions.0.description', + ]); +});