From c3f1c320a4e58593e6c341b17bc928219735f701 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 15:21:16 -0300 Subject: [PATCH 1/5] test: isolate PHPUnit database --- phpunit.xml | 1 + tests/TestCase.php | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 642274a..a3b9b10 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,7 @@ + diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..9b31cc8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,25 @@ namespace Tests; +use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use RuntimeException; abstract class TestCase extends BaseTestCase { - // + public function createApplication(): Application + { + $app = parent::createApplication(); + $connection = (string) $app['config']->get('database.default'); + $database = (string) $app['config']->get("database.connections.{$connection}.database"); + $usesInMemorySqlite = $connection === 'sqlite' && $database === ':memory:'; + + if (! $usesInMemorySqlite && ! str_ends_with(strtolower($database), '_test')) { + throw new RuntimeException( + "Unsafe test database [{$database}]. Tests may only use an in-memory SQLite database or a database ending in _test.", + ); + } + + return $app; + } } From 6fb60c9a73e6dde0e3a4befb758c906ec1406b57 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 15:21:28 -0300 Subject: [PATCH 2/5] 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'); From 56012853699f4e26eab4c32d458643457ecdc146 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 15:38:24 -0300 Subject: [PATCH 3/5] feat(payments): expose primary transfer candidate --- app/Domains/Purchase/Models/Purchase.php | 6 ++ .../Purchase/Resources/PurchaseResource.php | 73 +++++++++++++++++++ .../Checkout/PurchaseResponseLoader.php | 11 ++- .../Integration/TelepagosWebhookTest.php | 32 ++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 447db78..be71d57 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -145,6 +145,12 @@ class Purchase extends Model return $this->hasMany(TelepagosPayment::class, 'compra_id'); } + /** @return HasMany */ + public function telepagosPaymentCandidates(): HasMany + { + return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id'); + } + public function getTotalAmount(): float { if ($this->total !== null) { diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 1c69313..2b75285 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Resources; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Models\TelepagosPaymentCandidate; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -23,6 +24,7 @@ class PurchaseResource extends JsonResource $ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes()) ? (int) $this->resource->getAttribute('tickets_count') : null; + $paymentVerification = $this->resolvePaymentVerification(); $subtotal = $items->isNotEmpty() ? $items->reduce( @@ -58,6 +60,7 @@ class PurchaseResource extends JsonResource 'items' => PurchaseItemResource::collection($items), 'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount), 'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0), + 'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification), 'subtotal' => $this->formatMoney($subtotal), 'total' => $this->formatMoney($total), ]; @@ -77,4 +80,74 @@ class PurchaseResource extends JsonResource { return number_format((float) ($amount ?? 0), 2, '.', ''); } + + /** @return array|null */ + private function resolvePaymentVerification(): ?array + { + if ( + $this->status !== Purchase::STATUS_IN_REVIEW + || $this->payment_method !== 'transfer' + || ! $this->resource->relationLoaded('telepagosPaymentCandidates') + ) { + return null; + } + + $candidates = $this->resource + ->getRelation('telepagosPaymentCandidates') + ->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right)) + ->values(); + /** @var TelepagosPaymentCandidate|null $primary */ + $primary = $candidates->first(); + + return [ + 'status' => $primary === null ? 'pending' : 'candidate', + 'candidate_count' => $candidates->count(), + 'primary' => $primary === null ? null : [ + 'reason' => $primary->match_reason, + 'payment_amount' => $this->formatMoney($primary->payment_amount), + 'purchase_amount' => $this->formatMoney($primary->purchase_amount), + 'amount_difference' => $this->formatMoney($primary->amount_difference), + 'confidence' => $primary->confidence, + 'detected_at' => $primary->payment?->created_at?->toIso8601String(), + ], + 'reasons' => $candidates + ->pluck('match_reason') + ->unique() + ->values() + ->all(), + ]; + } + + private function comparePaymentCandidates( + TelepagosPaymentCandidate $left, + TelepagosPaymentCandidate $right, + ): int { + $reasonComparison = $this->paymentCandidateRank($left->match_reason) + <=> $this->paymentCandidateRank($right->match_reason); + + if ($reasonComparison !== 0) { + return $reasonComparison; + } + + $differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference; + + if ($differenceComparison !== 0) { + return $differenceComparison; + } + + $leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0; + $rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0; + + return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id); + } + + private function paymentCandidateRank(string $reason): int + { + return match ($reason) { + 'ambiguous_exact_match' => 0, + 'exact_dni_near_amount' => 1, + 'exact_amount_different_dni' => 2, + default => 3, + }; + } } diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php index 223a8ba..cf86d44 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -8,6 +8,15 @@ class PurchaseResponseLoader { public function load(Purchase $purchase): Purchase { - return $purchase->load(['tenant', 'items.imageAttachment']); + $relations = ['tenant', 'items.imageAttachment']; + + if ( + $purchase->status === Purchase::STATUS_IN_REVIEW + && $purchase->payment_method === 'transfer' + ) { + $relations[] = 'telepagosPaymentCandidates.payment'; + } + + return $purchase->load($relations); } } diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 728cc67..7dcdd75 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -445,6 +445,38 @@ class TelepagosWebhookTest extends TestCase ]); $this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $nearAmountPurchase->fresh()->status); $this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $exactAmountDifferentDniPurchase->fresh()->status); + + $higherPriorityPayment = TelepagosPayment::query()->create([ + 'cuit_buyer' => '20123456789', + 'amount' => 52, + 'operation_id' => 1, + 'transaction_id' => 'tx-primary-candidate', + ]); + $higherPriorityPayment->candidates()->create([ + 'compra_id' => $nearAmountPurchase->id, + 'dni_matches' => true, + 'amount_matches' => true, + 'payment_amount' => 52, + 'purchase_amount' => 52, + 'amount_difference' => 0, + 'match_reason' => 'ambiguous_exact_match', + 'confidence' => 'exact', + ]); + + app(CheckoutService::class)->submitForReview($nearAmountPurchase->fresh()); + $buyer = User::query()->findOrFail($nearAmountPurchase->user_id); + + $this->actingAs($buyer, 'sanctum') + ->getJson("/api/tenants/candidates/compras/{$nearAmountPurchase->id}") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.payment_verification.status', 'candidate') + ->assertJsonPath('data.payment_verification.candidate_count', 2) + ->assertJsonPath('data.payment_verification.primary.reason', 'ambiguous_exact_match') + ->assertJsonPath('data.payment_verification.primary.amount_difference', '0.00') + ->assertJsonPath('data.payment_verification.primary.confidence', 'exact') + ->assertJsonPath('data.payment_verification.reasons.0', 'ambiguous_exact_match') + ->assertJsonPath('data.payment_verification.reasons.1', 'exact_dni_near_amount'); } public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void From 9bca41bfa50188750dee13d23971fa6a13c2e5f7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 16:04:12 -0300 Subject: [PATCH 4/5] feat(payments): filter candidates by DNI distance --- .../Services/TelepagosWebhookService.php | 53 +++++++----- .../Models/TelepagosPaymentCandidate.php | 4 + .../Purchase/Resources/PurchaseResource.php | 1 + .../Purchase/Services/DniDistanceService.php | 82 +++++++++++++++++++ ...stance_to_telepagos_payment_candidates.php | 61 ++++++++++++++ .../Integration/TelepagosWebhookTest.php | 40 +++++++++ .../Unit/Purchase/DniDistanceServiceTest.php | 31 +++++++ 7 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 app/Domains/Purchase/Services/DniDistanceService.php create mode 100644 database/migrations/2026_08_27_010000_add_dni_distance_to_telepagos_payment_candidates.php create mode 100644 tests/Unit/Purchase/DniDistanceServiceTest.php 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)); + } +} From b61728514832c77af0eb18fa7ac5ad684afcf993 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 27 Aug 2026 16:40:32 -0300 Subject: [PATCH 5/5] feat(payments): rename match reason from 'exact_amount_different_dni' to 'exact_amount_near_dni' --- .../Services/TelepagosWebhookService.php | 2 +- .../Purchase/Resources/PurchaseResource.php | 2 +- ...20000_rename_near_dni_candidate_reason.php | 21 +++++++++++++++++++ .../Integration/TelepagosWebhookTest.php | 4 ++-- 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2026_08_27_020000_rename_near_dni_candidate_reason.php diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index fdc8a01..3e4e9c6 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -317,7 +317,7 @@ class TelepagosWebhookService abs((float) $purchaseAmount - (float) $amount), ), 'match_reason' => $amountMatches - ? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_different_dni') + ? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni') : 'exact_dni_near_amount', 'confidence' => $amountMatches ? ($dniMatches ? 'exact' : 'medium') diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 3a0e44a..3513ad1 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -147,7 +147,7 @@ class PurchaseResource extends JsonResource return match ($reason) { 'ambiguous_exact_match' => 0, 'exact_dni_near_amount' => 1, - 'exact_amount_different_dni' => 2, + 'exact_amount_near_dni' => 2, default => 3, }; } diff --git a/database/migrations/2026_08_27_020000_rename_near_dni_candidate_reason.php b/database/migrations/2026_08_27_020000_rename_near_dni_candidate_reason.php new file mode 100644 index 0000000..eedbd06 --- /dev/null +++ b/database/migrations/2026_08_27_020000_rename_near_dni_candidate_reason.php @@ -0,0 +1,21 @@ +where('match_reason', 'exact_amount_different_dni') + ->update(['match_reason' => 'exact_amount_near_dni']); + } + + public function down(): void + { + DB::table('telepagos_payment_candidates') + ->where('match_reason', 'exact_amount_near_dni') + ->update(['match_reason' => 'exact_amount_different_dni']); + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index b191bc4..f089953 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -455,7 +455,7 @@ class TelepagosWebhookTest extends TestCase 'payment_amount' => 50, 'purchase_amount' => 50, 'amount_difference' => 0, - 'match_reason' => 'exact_amount_different_dni', + 'match_reason' => 'exact_amount_near_dni', 'confidence' => 'medium', ]); $this->assertDatabaseHas('telepagos_payment_candidates', [ @@ -466,7 +466,7 @@ class TelepagosWebhookTest extends TestCase 'payment_dni' => '12345678', 'purchase_dni' => '12345087', 'amount_matches' => true, - 'match_reason' => 'exact_amount_different_dni', + 'match_reason' => 'exact_amount_near_dni', ]); $this->assertDatabaseMissing('telepagos_payment_candidates', [ 'telepagos_payment_id' => $payment->id,