diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index ec43f7e..fdc8a01 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -7,6 +7,7 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\TelepagosPayment; use App\Domains\Purchase\Models\TelepagosQr; use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Purchase\Services\DniDistanceService; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -17,6 +18,7 @@ class TelepagosWebhookService { public function __construct( private readonly CheckoutService $checkoutService, + private readonly DniDistanceService $dniDistance, ) {} /** @@ -84,10 +86,12 @@ class TelepagosWebhookService ->where('payment_method', 'transfer'); $purchases = (clone $eligiblePurchases) - ->where('transfer_payer_dni', $dni) ->where('total', $amount) ->latest() - ->get(); + ->get() + ->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null + && $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0) + ->values(); $compra = $purchases->count() === 1 ? $purchases->first() : null; @@ -115,6 +119,7 @@ class TelepagosWebhookService ->map(fn ($candidate): array => [ 'purchase_id' => $candidate->compra_id, 'match_reason' => $candidate->match_reason, + 'dni_distance' => $candidate->dni_distance, 'amount_difference' => $candidate->amount_difference, 'confidence' => $candidate->confidence, ]) @@ -255,25 +260,24 @@ class TelepagosWebhookService $maximumAmount = $this->normalizeAmount($numericAmount + $tolerance); return (clone $eligiblePurchases) - ->where(function ($query) use ($dni, $amount, $minimumAmount, $maximumAmount): void { - $query - ->where(function ($query) use ($dni, $minimumAmount, $maximumAmount): void { - $query - ->where('transfer_payer_dni', $dni) - ->whereBetween('total', [$minimumAmount, $maximumAmount]); - }) - ->orWhere(function ($query) use ($dni, $amount): void { - $query - ->where('total', $amount) - ->where(function ($query) use ($dni): void { - $query - ->whereNull('transfer_payer_dni') - ->orWhere('transfer_payer_dni', '!=', $dni); - }); - }); - }) + ->whereBetween('total', [$minimumAmount, $maximumAmount]) ->latest() - ->get(); + ->get() + ->filter(function (Purchase $purchase) use ($dni, $amount): bool { + if ($purchase->transfer_payer_dni === null) { + return false; + } + + $purchaseAmount = $this->normalizeAmount($purchase->total); + $distance = $this->dniDistance->distance( + $dni, + (string) $purchase->transfer_payer_dni, + ); + + return $distance === 0 + || ($purchaseAmount === $amount && $distance <= 2); + }) + ->values(); } /** @@ -293,12 +297,19 @@ class TelepagosWebhookService $candidatePurchases ->map(function (Purchase $purchase) use ($dni, $amount): array { $purchaseAmount = $this->normalizeAmount($purchase->total); - $dniMatches = $purchase->transfer_payer_dni === $dni; + $dniDistance = $this->dniDistance->distance( + $dni, + (string) $purchase->transfer_payer_dni, + ); + $dniMatches = $dniDistance === 0; $amountMatches = $purchaseAmount === $amount; return [ 'compra_id' => $purchase->id, 'dni_matches' => $dniMatches, + 'dni_distance' => $dniDistance, + 'payment_dni' => $dni, + 'purchase_dni' => $purchase->transfer_payer_dni, 'amount_matches' => $amountMatches, 'payment_amount' => $amount, 'purchase_amount' => $purchaseAmount, diff --git a/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php b/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php index 3c7cf0e..93152cc 100644 --- a/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php +++ b/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php @@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'telepagos_payment_id', 'compra_id', 'dni_matches', + 'dni_distance', + 'payment_dni', + 'purchase_dni', 'amount_matches', 'payment_amount', 'purchase_amount', @@ -27,6 +30,7 @@ class TelepagosPaymentCandidate extends Model 'telepagos_payment_id' => 'integer', 'compra_id' => 'integer', 'dni_matches' => 'boolean', + 'dni_distance' => 'integer', 'amount_matches' => 'boolean', 'payment_amount' => 'decimal:2', 'purchase_amount' => 'decimal:2', diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 2b75285..3a0e44a 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -104,6 +104,7 @@ class PurchaseResource extends JsonResource 'candidate_count' => $candidates->count(), 'primary' => $primary === null ? null : [ 'reason' => $primary->match_reason, + 'dni_distance' => $primary->dni_distance, 'payment_amount' => $this->formatMoney($primary->payment_amount), 'purchase_amount' => $this->formatMoney($primary->purchase_amount), 'amount_difference' => $this->formatMoney($primary->amount_difference), diff --git a/app/Domains/Purchase/Services/DniDistanceService.php b/app/Domains/Purchase/Services/DniDistanceService.php new file mode 100644 index 0000000..44d30d2 --- /dev/null +++ b/app/Domains/Purchase/Services/DniDistanceService.php @@ -0,0 +1,82 @@ +normalize($left); + $right = $this->normalize($right); + $leftLength = strlen($left); + $rightLength = strlen($right); + $matrix = []; + + // Transforming a prefix into an empty string requires deleting every digit. + for ($row = 0; $row <= $leftLength; $row++) { + $matrix[$row] = [$row]; + } + + // Transforming an empty string into a prefix requires inserting every digit. + for ($column = 0; $column <= $rightLength; $column++) { + $matrix[0][$column] = $column; + } + + for ($row = 1; $row <= $leftLength; $row++) { + for ($column = 1; $column <= $rightLength; $column++) { + $substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1; + $deletionDistance = $matrix[$row - 1][$column] + 1; + $insertionDistance = $matrix[$row][$column - 1] + 1; + $substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost; + + // Keep the cheapest way to align the two prefixes at this position. + $matrix[$row][$column] = min( + $deletionDistance, + $insertionDistance, + $substitutionDistance, + ); + + // Count two adjacent inverted digits as one edit instead of two substitutions. + if ( + $row > 1 + && $column > 1 + && $left[$row - 1] === $right[$column - 2] + && $left[$row - 2] === $right[$column - 1] + ) { + $matrix[$row][$column] = min( + $matrix[$row][$column], + $matrix[$row - 2][$column - 2] + 1, + ); + } + } + } + + return $matrix[$leftLength][$rightLength]; + } + + /** + * Keep only digits and left-pad seven-digit DNIs so comparisons preserve + * the leading zero that is present when the DNI is extracted from a CUIT. + */ + public function normalize(string $dni): string + { + $digits = preg_replace('/\D+/', '', $dni) ?? ''; + + return str_pad($digits, 8, '0', STR_PAD_LEFT); + } +} diff --git a/database/migrations/2026_08_27_010000_add_dni_distance_to_telepagos_payment_candidates.php b/database/migrations/2026_08_27_010000_add_dni_distance_to_telepagos_payment_candidates.php new file mode 100644 index 0000000..5138717 --- /dev/null +++ b/database/migrations/2026_08_27_010000_add_dni_distance_to_telepagos_payment_candidates.php @@ -0,0 +1,61 @@ +unsignedTinyInteger('dni_distance')->nullable()->after('dni_matches'); + $table->string('payment_dni', 8)->nullable()->after('dni_distance'); + $table->string('purchase_dni', 8)->nullable()->after('payment_dni'); + }); + + $dniDistance = new DniDistanceService; + + DB::table('telepagos_payment_candidates as candidate') + ->join('telepagos_payments as payment', 'payment.id', '=', 'candidate.telepagos_payment_id') + ->join('compras as purchase', 'purchase.id', '=', 'candidate.compra_id') + ->select([ + 'candidate.id', + 'candidate.match_reason', + 'payment.cuit_buyer', + 'purchase.transfer_payer_dni', + ]) + ->orderBy('candidate.id') + ->each(function (object $candidate) use ($dniDistance): void { + if ($candidate->cuit_buyer === null || $candidate->transfer_payer_dni === null) { + return; + } + + $payerDni = substr((string) $candidate->cuit_buyer, 2, -1); + $distance = $dniDistance->distance($payerDni, (string) $candidate->transfer_payer_dni); + + if ($candidate->match_reason === 'exact_amount_different_dni' && $distance > 2) { + DB::table('telepagos_payment_candidates')->where('id', $candidate->id)->delete(); + + return; + } + + DB::table('telepagos_payment_candidates') + ->where('id', $candidate->id) + ->update([ + 'dni_distance' => $distance, + 'payment_dni' => $payerDni, + 'purchase_dni' => $candidate->transfer_payer_dni, + ]); + }); + } + + public function down(): void + { + Schema::table('telepagos_payment_candidates', function (Blueprint $table) { + $table->dropColumn(['dni_distance', 'payment_dni', 'purchase_dni']); + }); + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 7dcdd75..b191bc4 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -351,6 +351,8 @@ class TelepagosWebhookTest extends TestCase $nearAmountVariant = $this->createVariantForTenant('candidates', 10, '52.00', 'near'); $exactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'exact'); + $distanceTwoExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'distance-two'); + $farExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'far-exact'); $nearAmountDifferentDniVariant = $this->createVariantForTenant('candidates', 10, '51.00', 'near-other-dni'); $outsideRangeVariant = $this->createVariantForTenant('candidates', 10, '100.00', 'outside'); @@ -366,8 +368,22 @@ class TelepagosWebhookTest extends TestCase User::factory()->create()->id, $exactAmountVariant->id, 1, + '12345687', + ); + $farExactAmountDifferentDniPurchase = $this->createPendingTransferPurchase( + $tenant, + User::factory()->create()->id, + $farExactAmountVariant->id, + 1, '87654321', ); + $distanceTwoExactAmountPurchase = $this->createPendingTransferPurchase( + $tenant, + User::factory()->create()->id, + $distanceTwoExactAmountVariant->id, + 1, + '12345087', + ); $nearAmountDifferentDniPurchase = $this->createPendingTransferPurchase( $tenant, User::factory()->create()->id, @@ -412,11 +428,15 @@ class TelepagosWebhookTest extends TestCase $this->assertEqualsCanonicalizing([ $nearAmountPurchase->id, $exactAmountDifferentDniPurchase->id, + $distanceTwoExactAmountPurchase->id, ], $payment->candidates()->pluck('compra_id')->all()); $this->assertDatabaseHas('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $nearAmountPurchase->id, 'dni_matches' => true, + 'dni_distance' => 0, + 'payment_dni' => '12345678', + 'purchase_dni' => '12345678', 'amount_matches' => false, 'payment_amount' => 50, 'purchase_amount' => 52, @@ -428,6 +448,9 @@ class TelepagosWebhookTest extends TestCase 'telepagos_payment_id' => $payment->id, 'compra_id' => $exactAmountDifferentDniPurchase->id, 'dni_matches' => false, + 'dni_distance' => 1, + 'payment_dni' => '12345678', + 'purchase_dni' => '12345687', 'amount_matches' => true, 'payment_amount' => 50, 'purchase_amount' => 50, @@ -435,6 +458,20 @@ class TelepagosWebhookTest extends TestCase 'match_reason' => 'exact_amount_different_dni', 'confidence' => 'medium', ]); + $this->assertDatabaseHas('telepagos_payment_candidates', [ + 'telepagos_payment_id' => $payment->id, + 'compra_id' => $distanceTwoExactAmountPurchase->id, + 'dni_matches' => false, + 'dni_distance' => 2, + 'payment_dni' => '12345678', + 'purchase_dni' => '12345087', + 'amount_matches' => true, + 'match_reason' => 'exact_amount_different_dni', + ]); + $this->assertDatabaseMissing('telepagos_payment_candidates', [ + 'telepagos_payment_id' => $payment->id, + 'compra_id' => $farExactAmountDifferentDniPurchase->id, + ]); $this->assertDatabaseMissing('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id, 'compra_id' => $nearAmountDifferentDniPurchase->id, @@ -455,6 +492,9 @@ class TelepagosWebhookTest extends TestCase $higherPriorityPayment->candidates()->create([ 'compra_id' => $nearAmountPurchase->id, 'dni_matches' => true, + 'dni_distance' => 0, + 'payment_dni' => '12345678', + 'purchase_dni' => '12345678', 'amount_matches' => true, 'payment_amount' => 52, 'purchase_amount' => 52, diff --git a/tests/Unit/Purchase/DniDistanceServiceTest.php b/tests/Unit/Purchase/DniDistanceServiceTest.php new file mode 100644 index 0000000..2e531f7 --- /dev/null +++ b/tests/Unit/Purchase/DniDistanceServiceTest.php @@ -0,0 +1,31 @@ + */ + public static function distances(): iterable + { + yield 'exact' => ['40123456', '40123456', 0]; + yield 'leading transposition' => ['40123456', '04123456', 1]; + yield 'trailing transposition' => ['40123456', '40123465', 1]; + yield 'single substitution' => ['40123456', '40123856', 1]; + yield 'substitution and transposition' => ['12345678', '12345087', 2]; + yield 'seven digits normalized with leading zero' => ['04123456', '4123456', 0]; + yield 'more than two edits' => ['40123456', '87654321', 7]; + } + + #[DataProvider('distances')] + public function test_it_calculates_damerau_levenshtein_distance( + string $left, + string $right, + int $expected, + ): void { + $this->assertSame($expected, (new DniDistanceService)->distance($left, $right)); + } +}