feat(payments): filter candidates by DNI distance

This commit is contained in:
ncoronel 2026-08-27 16:04:12 -03:00
parent 5601285369
commit 9bca41bfa5
7 changed files with 251 additions and 21 deletions

View File

@ -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,

View File

@ -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',

View File

@ -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),

View File

@ -0,0 +1,82 @@
<?php
namespace App\Domains\Purchase\Services;
/**
* Measures likely DNI typing errors using the optimal-string-alignment
* variant of the Damerau-Levenshtein distance.
*
* The returned value is the minimum number of single-character edits needed
* to transform one DNI into the other. Supported edits are insertion,
* deletion, substitution and transposition of two adjacent digits.
*/
class DniDistanceService
{
/**
* Calculate the edit distance between two normalized DNI strings.
*
* Each matrix cell [row][column] stores the minimum edits required to
* transform the first $row digits of $left into the first $column digits
* of $right. The bottom-right cell therefore contains the final distance.
*/
public function distance(string $left, string $right): int
{
$left = $this->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);
}
}

View File

@ -0,0 +1,61 @@
<?php
use App\Domains\Purchase\Services\DniDistanceService;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('telepagos_payment_candidates', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -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,

View File

@ -0,0 +1,31 @@
<?php
namespace Tests\Unit\Purchase;
use App\Domains\Purchase\Services\DniDistanceService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class DniDistanceServiceTest extends TestCase
{
/** @return iterable<string, array{string, string, int}> */
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));
}
}