diff --git a/app/Services/Banking/TransactionFingerprint.php b/app/Services/Banking/TransactionFingerprint.php index 29bbbc4c..ec63275d 100644 --- a/app/Services/Banking/TransactionFingerprint.php +++ b/app/Services/Banking/TransactionFingerprint.php @@ -5,10 +5,7 @@ namespace App\Services\Banking; /** * Builds a deterministic fingerprint for an EnableBanking transaction * payload so we can dedup even when the upstream bank omits a stable - * id (transaction_id / entry_reference). - * - * Shared between the live sync path and the cleanup command so they - * stay in lock-step. + * id (transaction_id / entry_reference), consumed by TransactionSyncService. */ class TransactionFingerprint { @@ -21,8 +18,24 @@ class TransactionFingerprint return self::hash(['transaction_id', $data['transaction_id']]); } - if (($data['entry_reference'] ?? null) !== null) { - return self::hash(['entry_reference', $data['entry_reference']]); + $entryReference = $data['entry_reference'] ?? null; + + // Some ASPSPs emit a positional `{booking_date}.{index}` entry_reference + // that is absent the day a transaction first appears and only populated + // on a later sync. Keying on it fingerprints the same transaction + // differently across syncs, so it slips past dedup and imports twice. + // Treat that positional form as "no stable id" and fall through to the + // content hash, which is identical on both syncs. + // + // Trade-off: the index is also the only field that would tell apart two + // genuinely distinct same-day transactions with byte-identical content + // (e.g. two identical tolls). Dropping it collapses them to one + // fingerprint, so only the first is kept. We accept that here — a rare + // silent under-count over the systematic duplication it fixes. Fixing + // both needs occurrence-aware dedup in the consumer (a schema change), + // tracked as a follow-up. + if ($entryReference !== null && ! self::isPositionalReference($entryReference)) { + return self::hash(['entry_reference', $entryReference]); } return self::hash([ @@ -43,6 +56,11 @@ class TransactionFingerprint ]); } + private static function isPositionalReference(string $reference): bool + { + return preg_match('/^\d{4}-\d{2}-\d{2}\.\d+$/D', $reference) === 1; + } + /** * @param array|string $remittance */ diff --git a/tests/Unit/Services/Banking/TransactionFingerprintTest.php b/tests/Unit/Services/Banking/TransactionFingerprintTest.php index f5ad025b..8a0345e8 100644 --- a/tests/Unit/Services/Banking/TransactionFingerprintTest.php +++ b/tests/Unit/Services/Banking/TransactionFingerprintTest.php @@ -41,6 +41,41 @@ test('entry reference is the canonical fingerprint when transaction id is absent ->toBe(TransactionFingerprint::for($changedPayload)); }); +test('positional entry reference is ignored so it matches the same transaction seen without one', function () { + $firstSync = baseEnableBankingPayload([ + 'transaction_id' => null, + 'entry_reference' => null, + ]); + + $laterSync = baseEnableBankingPayload([ + 'transaction_id' => null, + 'entry_reference' => '2025-05-12.0', + ]); + + expect(TransactionFingerprint::for($firstSync)) + ->toBe(TransactionFingerprint::for($laterSync)); +}); + +test('a non-positional entry reference is still the canonical fingerprint', function () { + // Only the exact `{date}.{index}` shape is treated as positional; anything + // else keeps keying on entry_reference so real bank references are honoured. + foreach (['2025-05-12', '2025-05-12.0.1', 'REF-2025-05-12.0', '2025-05-12.'] as $reference) { + $withReference = baseEnableBankingPayload([ + 'transaction_id' => null, + 'entry_reference' => $reference, + ]); + + $sameReferenceOtherContent = baseEnableBankingPayload([ + 'transaction_id' => null, + 'entry_reference' => $reference, + 'creditor' => ['name' => 'Different Merchant'], + ]); + + expect(TransactionFingerprint::for($withReference)) + ->toBe(TransactionFingerprint::for($sameReferenceOtherContent)); + } +}); + test('fallback fingerprint ignores volatile status and settlement dates', function () { $payload = baseEnableBankingPayload([ 'transaction_id' => null,