334 lines
14 KiB
PHP
334 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Integration\Services;
|
|
|
|
use App\Domains\Client\Models\Client;
|
|
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;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class TelepagosWebhookService
|
|
{
|
|
public function __construct(
|
|
private readonly CheckoutService $checkoutService,
|
|
private readonly DniDistanceService $dniDistance,
|
|
) {}
|
|
|
|
/**
|
|
* Handle the Telepagos webhook notification.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function handleWebhook(Client $client, string $cashinId): void
|
|
{
|
|
Log::channel('telepagos')->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<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)
|
|
->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<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);
|
|
$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');
|
|
});
|
|
}
|
|
}
|