From 6fb60c9a73e6dde0e3a4befb758c906ec1406b57 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 15:21:28 -0300 Subject: [PATCH] feat(payments): track transfer match candidates --- .env.example | 1 + .../Services/TelepagosWebhookService.php | 124 ++++++++++++++- .../Purchase/Models/TelepagosPayment.php | 9 +- .../Models/TelepagosPaymentCandidate.php | 46 ++++++ config/purchase.php | 5 + ...ate_telepagos_payment_candidates_table.php | 141 ++++++++++++++++++ .../Integration/TelepagosWebhookTest.php | 108 +++++++++++++- 7 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 app/Domains/Purchase/Models/TelepagosPaymentCandidate.php create mode 100644 database/migrations/2026_08_27_000000_create_telepagos_payment_candidates_table.php diff --git a/.env.example b/.env.example index 271a397..5d9ab73 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 05c4c3e..ec43f7e 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -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 $eligiblePurchases + * @return Collection + */ + 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 $paymentData + * @param Collection $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'); + }); + } } diff --git a/app/Domains/Purchase/Models/TelepagosPayment.php b/app/Domains/Purchase/Models/TelepagosPayment.php index 7bfb028..f91ed21 100644 --- a/app/Domains/Purchase/Models/TelepagosPayment.php +++ b/app/Domains/Purchase/Models/TelepagosPayment.php @@ -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 */ + public function candidates(): HasMany + { + return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id'); + } } diff --git a/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php b/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php new file mode 100644 index 0000000..3c7cf0e --- /dev/null +++ b/app/Domains/Purchase/Models/TelepagosPaymentCandidate.php @@ -0,0 +1,46 @@ + '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'); + } +} diff --git a/config/purchase.php b/config/purchase.php index 53037a9..f265790 100644 --- a/config/purchase.php +++ b/config/purchase.php @@ -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, + ), ]; diff --git a/database/migrations/2026_08_27_000000_create_telepagos_payment_candidates_table.php b/database/migrations/2026_08_27_000000_create_telepagos_payment_candidates_table.php new file mode 100644 index 0000000..0b4e588 --- /dev/null +++ b/database/migrations/2026_08_27_000000_create_telepagos_payment_candidates_table.php @@ -0,0 +1,141 @@ +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'; + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 3260e38..728cc67 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -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');