feat(payments): track transfer match candidates

This commit is contained in:
ncoronel 2026-08-27 15:21:28 -03:00
parent c3f1c320a4
commit 6fb60c9a73
7 changed files with 424 additions and 10 deletions

View File

@ -8,6 +8,7 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200

View File

@ -8,6 +8,8 @@ use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -41,7 +43,6 @@ class TelepagosWebhookService
$paymentData = [
'compra_id' => null,
'matched_purchase_ids' => null,
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
'amount' => $amount,
@ -73,24 +74,52 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
->where('transfer_payer_dni', $dni)
$eligiblePurchases = Purchase::query()
->whereIn('tenant_codigo', $tenantCodes)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer')
->where('payment_method', 'transfer');
$purchases = (clone $eligiblePurchases)
->where('transfer_payer_dni', $dni)
->where('total', $amount)
->latest()
->get();
$paymentData['matched_purchase_ids'] = $purchases->pluck('id')->all();
$compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) {
if ($purchases->count() > 1) {
TelepagosPayment::create($paymentData);
$candidatePurchases = $this->findTransferCandidates(
$eligiblePurchases,
$dni,
$amount,
);
if ($candidatePurchases->isNotEmpty()) {
$payment = $this->storeTransferCandidates(
$paymentData,
$candidatePurchases,
$dni,
$amount,
);
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'telepagos_payment_id' => $payment->id,
'amount' => $amount,
'candidate_count' => $payment->candidates->count(),
'candidates' => $payment->candidates
->map(fn ($candidate): array => [
'purchase_id' => $candidate->compra_id,
'match_reason' => $candidate->match_reason,
'amount_difference' => $candidate->amount_difference,
'confidence' => $candidate->confidence,
])
->all(),
]);
}
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
@ -209,4 +238,85 @@ class TelepagosWebhookService
{
return number_format((float) $amount, 2, '.', '');
}
/**
* @param Builder<Purchase> $eligiblePurchases
* @return Collection<int, Purchase>
*/
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
{
$tolerancePercentage = max(
0,
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
);
$numericAmount = (float) $amount;
$tolerance = $numericAmount * ($tolerancePercentage / 100);
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
$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);
});
});
})
->latest()
->get();
}
/**
* @param array<string, mixed> $paymentData
* @param Collection<int, Purchase> $candidatePurchases
*/
private function storeTransferCandidates(
array $paymentData,
Collection $candidatePurchases,
string $dni,
string $amount,
): TelepagosPayment {
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
$payment = TelepagosPayment::create($paymentData);
$payment->candidates()->createMany(
$candidatePurchases
->map(function (Purchase $purchase) use ($dni, $amount): array {
$purchaseAmount = $this->normalizeAmount($purchase->total);
$dniMatches = $purchase->transfer_payer_dni === $dni;
$amountMatches = $purchaseAmount === $amount;
return [
'compra_id' => $purchase->id,
'dni_matches' => $dniMatches,
'amount_matches' => $amountMatches,
'payment_amount' => $amount,
'purchase_amount' => $purchaseAmount,
'amount_difference' => $this->normalizeAmount(
abs((float) $purchaseAmount - (float) $amount),
),
'match_reason' => $amountMatches
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_different_dni')
: 'exact_dni_near_amount',
'confidence' => $amountMatches
? ($dniMatches ? 'exact' : 'medium')
: 'high',
];
})
->all(),
);
return $payment->load('candidates');
});
}
}

View File

@ -6,10 +6,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'compra_id',
'matched_purchase_ids',
'cuit_buyer',
'cvu_buyer',
'amount',
@ -30,7 +30,6 @@ class TelepagosPayment extends Model
{
return [
'compra_id' => 'integer',
'matched_purchase_ids' => 'array',
'amount' => 'decimal:2',
];
}
@ -42,4 +41,10 @@ class TelepagosPayment extends Model
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
/** @return HasMany<TelepagosPaymentCandidate, $this> */
public function candidates(): HasMany
{
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Domains\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'telepagos_payment_id',
'compra_id',
'dni_matches',
'amount_matches',
'payment_amount',
'purchase_amount',
'amount_difference',
'match_reason',
'confidence',
])]
class TelepagosPaymentCandidate extends Model
{
protected $table = 'telepagos_payment_candidates';
protected function casts(): array
{
return [
'telepagos_payment_id' => 'integer',
'compra_id' => 'integer',
'dni_matches' => 'boolean',
'amount_matches' => 'boolean',
'payment_amount' => 'decimal:2',
'purchase_amount' => 'decimal:2',
'amount_difference' => 'decimal:2',
];
}
public function payment(): BelongsTo
{
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
}

View File

@ -8,4 +8,9 @@ return [
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
],
'transfer_candidate_amount_tolerance_percentage' => (float) env(
'PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE',
5,
),
];

View File

@ -0,0 +1,141 @@
<?php
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::create('telepagos_payment_candidates', function (Blueprint $table) {
$table->id();
$table->foreignId('telepagos_payment_id')
->constrained('telepagos_payments')
->cascadeOnDelete();
$table->foreignId('compra_id')->constrained('compras')->cascadeOnDelete();
$table->boolean('dni_matches');
$table->boolean('amount_matches');
$table->decimal('payment_amount', 10, 2);
$table->decimal('purchase_amount', 10, 2);
$table->decimal('amount_difference', 10, 2);
$table->string('match_reason');
$table->string('confidence');
$table->timestamps();
$table->unique(
['telepagos_payment_id', 'compra_id'],
'telepagos_payment_candidate_unique',
);
});
$this->migrateExistingCandidates();
Schema::table('telepagos_payments', function (Blueprint $table) {
$table->dropColumn('matched_purchase_ids');
});
}
public function down(): void
{
Schema::table('telepagos_payments', function (Blueprint $table) {
$table->json('matched_purchase_ids')->nullable()->after('compra_id');
});
DB::table('telepagos_payments')
->whereNull('compra_id')
->orderBy('id')
->each(function (object $payment): void {
$candidateIds = DB::table('telepagos_payment_candidates')
->where('telepagos_payment_id', $payment->id)
->pluck('compra_id')
->all();
if ($candidateIds !== []) {
DB::table('telepagos_payments')
->where('id', $payment->id)
->update(['matched_purchase_ids' => json_encode($candidateIds)]);
}
});
Schema::dropIfExists('telepagos_payment_candidates');
}
private function migrateExistingCandidates(): void
{
DB::table('telepagos_payments')
->whereNull('compra_id')
->whereNotNull('matched_purchase_ids')
->orderBy('id')
->each(function (object $payment): void {
$candidateIds = json_decode((string) $payment->matched_purchase_ids, true);
if (! is_array($candidateIds)) {
return;
}
$paymentAmount = number_format((float) $payment->amount, 2, '.', '');
$payerDni = $payment->cuit_buyer
? substr((string) $payment->cuit_buyer, 2, -1)
: null;
foreach ($candidateIds as $candidateId) {
$purchase = DB::table('compras')->find($candidateId);
if ($purchase === null) {
continue;
}
$purchaseAmount = number_format((float) $purchase->total, 2, '.', '');
$dniMatches = $payerDni !== null && $purchase->transfer_payer_dni === $payerDni;
$amountMatches = $purchaseAmount === $paymentAmount;
DB::table('telepagos_payment_candidates')->insertOrIgnore([
'telepagos_payment_id' => $payment->id,
'compra_id' => $purchase->id,
'dni_matches' => $dniMatches,
'amount_matches' => $amountMatches,
'payment_amount' => $paymentAmount,
'purchase_amount' => $purchaseAmount,
'amount_difference' => number_format(
abs((float) $purchaseAmount - (float) $paymentAmount),
2,
'.',
'',
),
'match_reason' => $this->matchReason($dniMatches, $amountMatches),
'confidence' => $this->confidence($dniMatches, $amountMatches),
'created_at' => $payment->created_at,
'updated_at' => $payment->updated_at,
]);
}
});
}
private function matchReason(bool $dniMatches, bool $amountMatches): string
{
if ($dniMatches && $amountMatches) {
return 'ambiguous_exact_match';
}
if ($dniMatches) {
return 'exact_dni_near_amount';
}
if ($amountMatches) {
return 'exact_amount_different_dni';
}
return 'legacy_candidate';
}
private function confidence(bool $dniMatches, bool $amountMatches): string
{
if ($dniMatches && $amountMatches) {
return 'exact';
}
return $dniMatches ? 'high' : 'medium';
}
};

View File

@ -325,8 +325,9 @@ class TelepagosWebhookTest extends TestCase
->firstOrFail();
$this->assertEqualsCanonicalizing(
[$firstPurchase->id, $secondPurchase->id, $thirdPurchase->id],
$payment->matched_purchase_ids,
$payment->candidates()->pluck('compra_id')->all(),
);
$this->assertSame(3, $payment->candidates()->where('match_reason', 'ambiguous_exact_match')->count());
$this->assertDatabaseHas('compras', [
'id' => $firstPurchase->id,
'status' => Purchase::STATUS_PENDING_PAYMENT,
@ -341,6 +342,111 @@ class TelepagosWebhookTest extends TestCase
]);
}
public function test_transfer_webhook_records_near_amount_only_with_exact_dni_or_exact_amount_with_different_dni(): void
{
config(['purchase.transfer_candidate_amount_tolerance_percentage' => 5]);
$tenant = $this->createTenant('candidates', 'Candidates', 'candidates.com.ar');
$this->configureTelepagosIntegration($tenant);
$nearAmountVariant = $this->createVariantForTenant('candidates', 10, '52.00', 'near');
$exactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'exact');
$nearAmountDifferentDniVariant = $this->createVariantForTenant('candidates', 10, '51.00', 'near-other-dni');
$outsideRangeVariant = $this->createVariantForTenant('candidates', 10, '100.00', 'outside');
$nearAmountPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$nearAmountVariant->id,
1,
'12345678',
);
$exactAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$exactAmountVariant->id,
1,
'87654321',
);
$nearAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$nearAmountDifferentDniVariant->id,
1,
'87654321',
);
$outsideRangePurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$outsideRangeVariant->id,
1,
'12345678',
);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'test-token',
'expires_at' => now()->addHour()->toIso8601String(),
]),
'https://api.telepagos.com.ar/v2/payment/cashin/candidates' => Http::response([
'status' => 'ok',
'data' => [
'amount' => 50,
'operation_id' => 1,
'transaction_id' => 'tx-candidates',
'buyer' => ['cuit' => '20123456789'],
],
]),
]);
$this->postJson('/api/webhooks/telepagos/candidates', ['id' => 'candidates'])
->assertOk()
->assertJsonPath('status', 'success');
$payment = TelepagosPayment::query()
->where('transaction_id', 'tx-candidates')
->firstOrFail();
$this->assertNull($payment->compra_id);
$this->assertEqualsCanonicalizing([
$nearAmountPurchase->id,
$exactAmountDifferentDniPurchase->id,
], $payment->candidates()->pluck('compra_id')->all());
$this->assertDatabaseHas('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $nearAmountPurchase->id,
'dni_matches' => true,
'amount_matches' => false,
'payment_amount' => 50,
'purchase_amount' => 52,
'amount_difference' => 2,
'match_reason' => 'exact_dni_near_amount',
'confidence' => 'high',
]);
$this->assertDatabaseHas('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $exactAmountDifferentDniPurchase->id,
'dni_matches' => false,
'amount_matches' => true,
'payment_amount' => 50,
'purchase_amount' => 50,
'amount_difference' => 0,
'match_reason' => 'exact_amount_different_dni',
'confidence' => 'medium',
]);
$this->assertDatabaseMissing('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $nearAmountDifferentDniPurchase->id,
]);
$this->assertDatabaseMissing('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $outsideRangePurchase->id,
]);
$this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $nearAmountPurchase->fresh()->status);
$this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $exactAmountDifferentDniPurchase->fresh()->status);
}
public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void
{
$tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');