feat(payments): filter candidates by DNI distance
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user