info('Telepagos webhook received.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, ]); $telepagosService = new TelepagosIntegrationService; $telepagosService->forClient($client); try { $details = $telepagosService->getCashinDetails($cashinId); $qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null; $amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0); $operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null; $paymentData = [ 'compra_id' => null, 'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null, 'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null, 'amount' => $amount, 'concept' => $details['data']['concept'] ?? $details['concept'] ?? null, 'operation' => $details['data']['operation'] ?? $details['operation'] ?? null, 'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null, 'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null, 'qr_order_id' => $qrOrderId, 'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null, ]; $transferenciaOperationIds = [1, 3, 11]; $qrOperationIds = [31, 37, 47]; $compra = null; if (in_array((int) $operationId, $transferenciaOperationIds, true)) { $cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null; if (! $cuit) { Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, ]); return; } $dni = substr($cuit, 2, -1); $tenantCodes = $client->tenants()->pluck('codigo'); $eligiblePurchases = Purchase::query() ->whereIn('tenant_codigo', $tenantCodes) ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW, ]) ->where('payment_method', 'transfer'); $purchases = (clone $eligiblePurchases) ->where('total', $amount) ->latest() ->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; if (! $compra) { $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, 'dni_distance' => $candidate->dni_distance, 'amount_difference' => $candidate->amount_difference, 'confidence' => $candidate->confidence, ]) ->all(), ]); } Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'amount' => $amount, 'matches' => $purchases->count(), ]); return; } } elseif (in_array((int) $operationId, $qrOperationIds, true)) { if (! $qrOrderId) { Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, ]); return; } $telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first(); if (! $telepagosQr) { Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'qr_order_id' => $qrOrderId, ]); return; } $compra = $telepagosQr->compra; if (! $compra) { Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'qr_order_id' => $qrOrderId, ]); return; } if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) { Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'purchase_id' => $compra->id, ]); return; } if (! in_array($compra->status, [ Purchase::STATUS_PENDING_PAYMENT, ], true)) { Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'purchase_id' => $compra->id, 'purchase_status' => $compra->status, ]); return; } $totalAmount = $this->normalizeAmount($compra->getTotalAmount()); if ($amount !== $totalAmount) { Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'purchase_id' => $compra->id, 'cashin_amount' => $amount, 'purchase_amount' => $totalAmount, ]); return; } } else { Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'operation_id' => $operationId, ]); return; } $paymentData['compra_id'] = $compra->id; DB::transaction(function () use ($compra, $paymentData) { TelepagosPayment::create($paymentData); $this->checkoutService->confirmPaidPurchase($compra); }); Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'purchase_id' => $compra->id, 'transaction_id' => $paymentData['transaction_id'], ]); } catch (Exception $e) { Log::channel('telepagos')->error('Telepagos webhook processing failed.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, 'error' => $e->getMessage(), ]); throw $e; } } protected function normalizeAmount(mixed $amount): string { 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) ->whereBetween('total', [$minimumAmount, $maximumAmount]) ->latest() ->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(); } /** * @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); $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, 'amount_difference' => $this->normalizeAmount( abs((float) $purchaseAmount - (float) $amount), ), 'match_reason' => $amountMatches ? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni') : 'exact_dni_near_amount', 'confidence' => $amountMatches ? ($dniMatches ? 'exact' : 'medium') : 'high', ]; }) ->all(), ); return $payment->load('candidates'); }); } }