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