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:
parent
bdffa91bb1
commit
a31985822c
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue