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', + ]); +});