fix(import): match Unicode whitespace in server-side duplicate detection

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.
This commit is contained in:
Víctor Falcón 2026-07-03 16:05:36 +02:00
parent bdffa91bb1
commit a31985822c
2 changed files with 64 additions and 1 deletions

View File

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

View File

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