|
|
@@ -12,7 +12,10 @@ use App\Models\PaymentSplit;
|
|
|
use App\Models\Schedule;
|
|
|
use App\Services\Pagarme\PagarmePaymentService;
|
|
|
use Carbon\Carbon;
|
|
|
+use Illuminate\Auth\Access\AuthorizationException;
|
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
|
+use Illuminate\Support\Collection as SupportCollection;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
|
class PaymentService
|
|
|
@@ -24,7 +27,7 @@ class PaymentService
|
|
|
public function getAll(): Collection
|
|
|
{
|
|
|
return Payment::query()
|
|
|
- ->with(['client.user', 'provider.user', 'schedule'])
|
|
|
+ ->with(['client.user', 'provider.user', 'schedule', 'cart.items.schedule'])
|
|
|
->orderBy('created_at', 'desc')
|
|
|
->get();
|
|
|
}
|
|
|
@@ -32,7 +35,7 @@ class PaymentService
|
|
|
public function findById(int $id): ?Payment
|
|
|
{
|
|
|
return Payment::query()
|
|
|
- ->with(['client.user', 'provider.user', 'schedule'])
|
|
|
+ ->with(['client.user', 'provider.user', 'schedule', 'cart.items.schedule'])
|
|
|
->find($id);
|
|
|
}
|
|
|
|
|
|
@@ -99,6 +102,8 @@ class PaymentService
|
|
|
throw new \InvalidArgumentException('Prestador precisa ter recipient_id do Pagar.me para receber split.');
|
|
|
}
|
|
|
|
|
|
+ $this->ensureScheduleCanBePaidIndividually($schedule);
|
|
|
+
|
|
|
$existingPayment = Payment::query()
|
|
|
->where('schedule_id', $schedule->id)
|
|
|
->whereIn('status', [
|
|
|
@@ -247,10 +252,189 @@ class PaymentService
|
|
|
return $payment;
|
|
|
}
|
|
|
|
|
|
+ public function payCart(
|
|
|
+ Cart $cart,
|
|
|
+ int $userId,
|
|
|
+ string $paymentMethod,
|
|
|
+ ?int $clientPaymentMethodId = null,
|
|
|
+ array $options = [],
|
|
|
+ ): Payment {
|
|
|
+ if ($cart->client?->user_id !== $userId) {
|
|
|
+ throw new AuthorizationException;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! in_array($paymentMethod, ['credit_card', 'pix'], true)) {
|
|
|
+ throw new \InvalidArgumentException('Forma de pagamento invalida.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $paymentData = DB::transaction(function () use (
|
|
|
+ $cart,
|
|
|
+ $userId,
|
|
|
+ $paymentMethod,
|
|
|
+ $clientPaymentMethodId,
|
|
|
+ $options,
|
|
|
+ ): array {
|
|
|
+ $cart = Cart::query()
|
|
|
+ ->lockForUpdate()
|
|
|
+ ->with(['client', 'provider', 'items.schedule.client', 'items.schedule.provider', 'items.schedule.customSchedule.serviceType'])
|
|
|
+ ->findOrFail($cart->id);
|
|
|
+
|
|
|
+ if ($cart->client?->user_id !== $userId) {
|
|
|
+ throw new AuthorizationException;
|
|
|
+ }
|
|
|
+
|
|
|
+ $schedules = $cart->items->pluck('schedule')->filter()->values();
|
|
|
+
|
|
|
+ $this->validateCartForPayment($cart, $schedules);
|
|
|
+
|
|
|
+ $existingPayment = Payment::query()
|
|
|
+ ->where('cart_id', $cart->id)
|
|
|
+ ->whereIn('status', [
|
|
|
+ PaymentStatusEnum::PENDING->value,
|
|
|
+ PaymentStatusEnum::PROCESSING->value,
|
|
|
+ PaymentStatusEnum::AUTHORIZED->value,
|
|
|
+ PaymentStatusEnum::PAID->value,
|
|
|
+ ])
|
|
|
+ ->latest('id')
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ if ($existingPayment) {
|
|
|
+ if ($this->isExpiredPixPayment($existingPayment)) {
|
|
|
+ $existingPayment->forceFill([
|
|
|
+ 'status' => PaymentStatusEnum::FAILED,
|
|
|
+ 'failed_at' => now(),
|
|
|
+ 'failure_message' => 'Pagamento Pix expirado.',
|
|
|
+ ])->save();
|
|
|
+
|
|
|
+ PaymentSplit::query()
|
|
|
+ ->where('payment_id', $existingPayment->id)
|
|
|
+ ->update(['status' => PaymentSplitStatusEnum::FAILED]);
|
|
|
+ } elseif ($this->isStaleIncompleteGatewayPayment($existingPayment)) {
|
|
|
+ if ($existingPayment->payment_method !== $paymentMethod) {
|
|
|
+ throw new \InvalidArgumentException('Ja existe um pagamento em andamento para este carrinho.');
|
|
|
+ }
|
|
|
+
|
|
|
+ [, $cardId] = $this->resolveCard(
|
|
|
+ clientId: $cart->client_id,
|
|
|
+ paymentMethod: $paymentMethod,
|
|
|
+ clientPaymentMethodId: $clientPaymentMethodId ?? $existingPayment->client_payment_method_id,
|
|
|
+ cardId: $options['card_id'] ?? null,
|
|
|
+ );
|
|
|
+
|
|
|
+ $this->cartPaymentTotals($schedules, $paymentMethod);
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'payment' => $existingPayment,
|
|
|
+ 'schedules' => $schedules,
|
|
|
+ 'cardId' => $cardId,
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ if ($existingPayment->payment_method !== $paymentMethod && $existingPayment->status !== PaymentStatusEnum::PAID) {
|
|
|
+ throw new \InvalidArgumentException('Ja existe um pagamento em andamento para este carrinho.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['existing' => $existingPayment];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($cart->status !== CartStatusEnum::OPEN) {
|
|
|
+ throw new \InvalidArgumentException('Carrinho precisa estar aberto para ser pago.');
|
|
|
+ }
|
|
|
+
|
|
|
+ [$clientPaymentMethod, $cardId] = $this->resolveCard(
|
|
|
+ clientId: $cart->client_id,
|
|
|
+ paymentMethod: $paymentMethod,
|
|
|
+ clientPaymentMethodId: $clientPaymentMethodId,
|
|
|
+ cardId: $options['card_id'] ?? null,
|
|
|
+ );
|
|
|
+
|
|
|
+ $totals = $this->cartPaymentTotals($schedules, $paymentMethod);
|
|
|
+
|
|
|
+ $payment = Payment::create([
|
|
|
+ 'schedule_id' => null,
|
|
|
+ 'cart_id' => $cart->id,
|
|
|
+ 'client_id' => $cart->client_id,
|
|
|
+ 'provider_id' => $cart->provider_id,
|
|
|
+ 'client_payment_method_id' => $paymentMethod === 'credit_card' ? $clientPaymentMethod?->id : null,
|
|
|
+ 'gateway_provider' => 'pagarme',
|
|
|
+ 'gateway_code' => 'payment-'.(string) Str::uuid(),
|
|
|
+ 'payment_method' => $paymentMethod,
|
|
|
+ 'status' => PaymentStatusEnum::PENDING,
|
|
|
+ 'gross_amount' => $totals['gross_amount'],
|
|
|
+ 'gateway_fee_amount' => 0,
|
|
|
+ 'platform_fee_amount' => $totals['platform_fee_amount'],
|
|
|
+ 'net_amount' => $totals['gross_amount'],
|
|
|
+ 'currency' => 'BRL',
|
|
|
+ 'installments' => 1,
|
|
|
+ 'expires_at' => $paymentMethod === 'pix' ? Carbon::now()->addMinutes(30) : null,
|
|
|
+ 'metadata' => [
|
|
|
+ 'cart_id' => (string) $cart->id,
|
|
|
+ 'schedule_ids' => $schedules->pluck('id')->map(fn ($id) => (string) $id)->all(),
|
|
|
+ 'service_amount' => number_format($totals['service_amount'], 2, '.', ''),
|
|
|
+ 'platform_fee' => number_format($totals['platform_fee_amount'], 2, '.', ''),
|
|
|
+ ],
|
|
|
+ ]);
|
|
|
+
|
|
|
+ PaymentSplit::create([
|
|
|
+ 'payment_id' => $payment->id,
|
|
|
+ 'provider_id' => $cart->provider_id,
|
|
|
+ 'gateway_provider' => 'pagarme',
|
|
|
+ 'gateway_transfer_target_reference' => $cart->provider->recipient_id,
|
|
|
+ 'gateway_transfer_target_label' => 'recipient',
|
|
|
+ 'status' => PaymentSplitStatusEnum::PENDING,
|
|
|
+ 'gross_amount' => $totals['service_amount'],
|
|
|
+ 'gateway_fee_amount' => 0,
|
|
|
+ 'net_amount' => $totals['service_amount'],
|
|
|
+ 'metadata' => [
|
|
|
+ 'cart_id' => (string) $cart->id,
|
|
|
+ 'schedule_ids' => $schedules->pluck('id')->map(fn ($id) => (string) $id)->all(),
|
|
|
+ ],
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return compact('payment', 'schedules', 'cardId');
|
|
|
+ });
|
|
|
+
|
|
|
+ if (isset($paymentData['existing'])) {
|
|
|
+ $existingPayment = $paymentData['existing'];
|
|
|
+ $this->syncPaymentTargets($existingPayment);
|
|
|
+
|
|
|
+ return $existingPayment->fresh(['client.user', 'provider.user', 'cart.items.schedule']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @var Payment $payment */
|
|
|
+ $payment = $paymentData['payment'];
|
|
|
+ /** @var SupportCollection $schedules */
|
|
|
+ $schedules = $paymentData['schedules'];
|
|
|
+
|
|
|
+ try {
|
|
|
+ $schedules->first()->ensureCustomerPhone($options['phone'] ?? null);
|
|
|
+
|
|
|
+ $orderResponse = $this->pagarmePaymentService->processCartPayment(
|
|
|
+ payment: $payment,
|
|
|
+ schedules: $schedules,
|
|
|
+ paymentMethod: $paymentMethod,
|
|
|
+ cardId: $paymentData['cardId'],
|
|
|
+ options: $options,
|
|
|
+ );
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ $this->failPayment($payment, $e->getMessage());
|
|
|
+
|
|
|
+ throw $e;
|
|
|
+ }
|
|
|
+
|
|
|
+ $payment = $this->pagarmePaymentService->applyGatewayResponseToPayment($payment, $orderResponse);
|
|
|
+
|
|
|
+ $this->syncPaymentTargets($payment);
|
|
|
+
|
|
|
+ return $payment->fresh(['client.user', 'provider.user', 'cart.items.schedule']);
|
|
|
+ }
|
|
|
+
|
|
|
//
|
|
|
|
|
|
public function getOrCreatePixPayment(Schedule $schedule): Payment
|
|
|
{
|
|
|
+ $this->ensureScheduleCanBePaidIndividually($schedule);
|
|
|
+
|
|
|
$existingPayment = Payment::query()
|
|
|
->where('schedule_id', $schedule->id)
|
|
|
->where('payment_method', 'pix')
|
|
|
@@ -325,6 +509,12 @@ class PaymentService
|
|
|
&& empty($payment->gateway_payload);
|
|
|
}
|
|
|
|
|
|
+ private function isStaleIncompleteGatewayPayment(Payment $payment): bool
|
|
|
+ {
|
|
|
+ return $this->isIncompleteGatewayPayment($payment)
|
|
|
+ && ($payment->created_at?->lte(now()->subMinutes(5)) ?? false);
|
|
|
+ }
|
|
|
+
|
|
|
public function syncScheduleStatusAfterPayment(Schedule $schedule, Payment $payment): void
|
|
|
{
|
|
|
if ($payment->status !== PaymentStatusEnum::PAID) {
|
|
|
@@ -338,6 +528,42 @@ class PaymentService
|
|
|
$this->syncCartsForSchedule($schedule);
|
|
|
}
|
|
|
|
|
|
+ public function syncPaymentTargets(Payment $payment): void
|
|
|
+ {
|
|
|
+ if ($payment->status !== PaymentStatusEnum::PAID) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($payment->cart_id) {
|
|
|
+ DB::transaction(function () use ($payment): void {
|
|
|
+ $cart = Cart::query()->lockForUpdate()->with('items')->find($payment->cart_id);
|
|
|
+
|
|
|
+ if (! $cart) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $scheduleIds = $cart->items->pluck('schedule_id')->filter()->unique();
|
|
|
+
|
|
|
+ Schedule::query()
|
|
|
+ ->whereIn('id', $scheduleIds)
|
|
|
+ ->where('status', 'accepted')
|
|
|
+ ->update(['status' => 'paid']);
|
|
|
+
|
|
|
+ if ($cart->status !== CartStatusEnum::PAID) {
|
|
|
+ $cart->update(['status' => CartStatusEnum::PAID->value]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $payment->loadMissing('schedule');
|
|
|
+
|
|
|
+ if ($payment->schedule) {
|
|
|
+ $this->syncScheduleStatusAfterPayment($payment->schedule, $payment);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private function syncCartsForSchedule(Schedule $schedule): void
|
|
|
{
|
|
|
Cart::query()
|
|
|
@@ -361,11 +587,10 @@ class PaymentService
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- $paidSchedulesCount = Payment::query()
|
|
|
- ->whereIn('schedule_id', $scheduleIds)
|
|
|
- ->where('status', PaymentStatusEnum::PAID->value)
|
|
|
- ->distinct('schedule_id')
|
|
|
- ->count('schedule_id');
|
|
|
+ $paidSchedulesCount = Schedule::query()
|
|
|
+ ->whereIn('id', $scheduleIds)
|
|
|
+ ->where('status', 'paid')
|
|
|
+ ->count();
|
|
|
|
|
|
if ($paidSchedulesCount !== $scheduleIds->count()) {
|
|
|
return;
|
|
|
@@ -375,4 +600,126 @@ class PaymentService
|
|
|
$cart->update(['status' => CartStatusEnum::PAID->value]);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ private function validateCartForPayment(Cart $cart, SupportCollection $schedules): void
|
|
|
+ {
|
|
|
+ if (! in_array($cart->status, [CartStatusEnum::OPEN, CartStatusEnum::PAID], true)) {
|
|
|
+ throw new \InvalidArgumentException('Carrinho precisa estar aberto para ser pago.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($schedules->isEmpty()) {
|
|
|
+ throw new \InvalidArgumentException('Carrinho precisa ter ao menos um agendamento.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $providerIds = $schedules->pluck('provider_id')->filter()->unique()->values();
|
|
|
+ $clientIds = $schedules->pluck('client_id')->unique()->values();
|
|
|
+
|
|
|
+ if (! $cart->provider_id || $providerIds->count() !== 1 || (int) $providerIds->first() !== $cart->provider_id) {
|
|
|
+ throw new \InvalidArgumentException('Todos os agendamentos do carrinho precisam ser do mesmo prestador.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($clientIds->count() !== 1 || (int) $clientIds->first() !== $cart->client_id) {
|
|
|
+ throw new \InvalidArgumentException('Todos os agendamentos do carrinho precisam ser do mesmo cliente.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (empty($cart->provider?->recipient_id)) {
|
|
|
+ throw new \InvalidArgumentException('Prestador precisa ter recipient_id do Pagar.me para receber split.');
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($schedules as $schedule) {
|
|
|
+ $expectedStatus = $cart->status === CartStatusEnum::PAID ? 'paid' : 'accepted';
|
|
|
+
|
|
|
+ if ($schedule->status !== $expectedStatus) {
|
|
|
+ throw new \InvalidArgumentException("Agendamento {$schedule->id} precisa estar aceito para o carrinho ser pago.");
|
|
|
+ }
|
|
|
+
|
|
|
+ if ((float) $schedule->total_amount <= 0) {
|
|
|
+ throw new \InvalidArgumentException("Agendamento {$schedule->id} precisa ter valor maior que zero para gerar pagamento.");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private function resolveCard(
|
|
|
+ int $clientId,
|
|
|
+ string $paymentMethod,
|
|
|
+ ?int $clientPaymentMethodId,
|
|
|
+ ?string $cardId,
|
|
|
+ ): array {
|
|
|
+ if ($paymentMethod !== 'credit_card') {
|
|
|
+ return [null, null];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! $clientPaymentMethodId && empty($cardId)) {
|
|
|
+ throw new \InvalidArgumentException('Cartao de pagamento ou card_id e obrigatorio.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $clientPaymentMethod = $clientPaymentMethodId
|
|
|
+ ? ClientPaymentMethod::query()
|
|
|
+ ->where('client_id', $clientId)
|
|
|
+ ->where('id', $clientPaymentMethodId)
|
|
|
+ ->where('is_active', true)
|
|
|
+ ->first()
|
|
|
+ : null;
|
|
|
+
|
|
|
+ if ($clientPaymentMethodId && ! $clientPaymentMethod) {
|
|
|
+ throw new \InvalidArgumentException('Cartao de pagamento nao encontrado ou inativo para este cliente.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $cardId = $cardId ?: $clientPaymentMethod?->gateway_card_id;
|
|
|
+
|
|
|
+ if (empty($cardId)) {
|
|
|
+ throw new \InvalidArgumentException('Cartao de pagamento invalido ou sem gateway_card_id do Pagar.me.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return [$clientPaymentMethod, $cardId];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function cartPaymentTotals(SupportCollection $schedules, string $paymentMethod): array
|
|
|
+ {
|
|
|
+ return $schedules->reduce(function (array $totals, Schedule $schedule) use ($paymentMethod): array {
|
|
|
+ $amounts = $this->pagarmePaymentService->calculatePaymentAmounts(
|
|
|
+ serviceAmount: (float) $schedule->total_amount,
|
|
|
+ paymentMethod: $paymentMethod,
|
|
|
+ schedule: $schedule,
|
|
|
+ );
|
|
|
+
|
|
|
+ $schedule->setAttribute('payment_gross_amount', $amounts['gross_amount']);
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'service_amount' => round($totals['service_amount'] + $amounts['service_amount'], 2),
|
|
|
+ 'platform_fee_amount' => round($totals['platform_fee_amount'] + $amounts['platform_fee_amount'], 2),
|
|
|
+ 'gross_amount' => round($totals['gross_amount'] + $amounts['gross_amount'], 2),
|
|
|
+ ];
|
|
|
+ }, [
|
|
|
+ 'service_amount' => 0.0,
|
|
|
+ 'platform_fee_amount' => 0.0,
|
|
|
+ 'gross_amount' => 0.0,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function failPayment(Payment $payment, string $message): void
|
|
|
+ {
|
|
|
+ $payment->forceFill([
|
|
|
+ 'status' => PaymentStatusEnum::FAILED,
|
|
|
+ 'failed_at' => now(),
|
|
|
+ 'failure_message' => $message,
|
|
|
+ ])->save();
|
|
|
+
|
|
|
+ PaymentSplit::query()
|
|
|
+ ->where('payment_id', $payment->id)
|
|
|
+ ->update(['status' => PaymentSplitStatusEnum::FAILED]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function ensureScheduleCanBePaidIndividually(Schedule $schedule): void
|
|
|
+ {
|
|
|
+ $belongsToMultiItemCart = Cart::query()
|
|
|
+ ->where('status', CartStatusEnum::OPEN->value)
|
|
|
+ ->whereHas('items', fn ($query) => $query->where('schedule_id', $schedule->id))
|
|
|
+ ->whereHas('items', null, '>', 1)
|
|
|
+ ->exists();
|
|
|
+
|
|
|
+ if ($belongsToMultiItemCart) {
|
|
|
+ throw new \InvalidArgumentException('Agendamento pertencente a carrinho com múltiplos itens deve ser pago pelo checkout do carrinho.');
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|