Kaynağa Gözat

refactor: fluxo de cart agora permite somente schedules do mesmo prestador

Gustavo Mantovani 1 hafta önce
ebeveyn
işleme
73ec7aaa33

+ 39 - 0
app/Http/Controllers/PaymentController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Enums\PaymentStatusEnum;
 use App\Http\Requests\PaymentRequest;
 use App\Http\Resources\PaymentResource;
+use App\Models\Cart;
 use App\Models\Schedule;
 use App\Services\PaymentService;
 use Illuminate\Http\JsonResponse;
@@ -96,6 +97,44 @@ class PaymentController extends Controller
         );
     }
 
+    public function payCart(Request $request, Cart $cart): JsonResponse
+    {
+        $validated = $request->validate([
+            'payment_method'           => ['required', 'in:credit_card,pix'],
+            'client_payment_method_id' => ['nullable', 'integer', 'exists:client_payment_methods,id'],
+            'card_id'                  => ['nullable', 'string', 'max:255'],
+            'phone'                    => ['nullable', 'string', 'max:20'],
+        ]);
+
+        try {
+            $item = $this->service->payCart(
+                cart:                  $cart,
+                userId:                (int) Auth::id(),
+                paymentMethod:         $validated['payment_method'],
+                clientPaymentMethodId: $validated['client_payment_method_id'] ?? null,
+                options:               [
+                    'phone'   => $validated['phone'] ?? null,
+                    'card_id' => $validated['card_id'] ?? null,
+                ],
+            );
+        } catch (\InvalidArgumentException $e) {
+            return $this->errorResponse(message: $e->getMessage(), code: 422);
+        }
+
+        if ($item->status === PaymentStatusEnum::FAILED) {
+            return response()->json([
+                'payload' => new PaymentResource($item),
+                'message' => $item->failure_message ?: $this->paymentMessage($item->status->value),
+            ], 422);
+        }
+
+        return $this->successResponse(
+            payload: new PaymentResource($item),
+            message: $this->paymentMessage($item->status->value),
+            code:    201,
+        );
+    }
+
     //
 
     public function getSchedulePix(Schedule $schedule): JsonResponse

+ 28 - 0
app/Http/Requests/CartRequest.php

@@ -3,6 +3,7 @@
 namespace App\Http\Requests;
 
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Validator;
 
 class CartRequest extends FormRequest
 {
@@ -62,6 +63,33 @@ class CartRequest extends FormRequest
         return $rules;
     }
 
+    public function after(): array
+    {
+        return [function (Validator $validator): void {
+            if (! $this->hasSchedulePayload()) {
+                return;
+            }
+
+            $rootProviderId = $this->integer('provider_id') ?: null;
+            $providerIds = collect($this->input('schedules', []))
+                ->map(fn (array $schedule) => $schedule['provider_id'] ?? $rootProviderId)
+                ->when(
+                    ! $this->filled('schedules') && $rootProviderId,
+                    fn ($ids) => $ids->push($rootProviderId),
+                )
+                ->filter()
+                ->unique()
+                ->values();
+
+            if ($providerIds->count() > 1) {
+                $validator->errors()->add(
+                    'schedules',
+                    'Todos os agendamentos do carrinho precisam ser do mesmo prestador.',
+                );
+            }
+        }];
+    }
+
     private function hasSchedulePayload(): bool
     {
         return $this->filled('date')

+ 1 - 0
app/Http/Resources/CartResource.php

@@ -19,6 +19,7 @@ class CartResource extends JsonResource
         return [
             'id'           => $this->id,
             'client_id'    => $this->client_id,
+            'provider_id'  => $this->provider_id,
             'status'       => $this->status?->value,
             'schedule_ids' => $this->whenLoaded('items', fn () => $this->items->pluck('schedule_id')->values()),
             'items'        => $this->whenLoaded('items', fn () => CartItemResource::collection($this->items)),

+ 5 - 0
app/Http/Resources/PaymentResource.php

@@ -20,6 +20,11 @@ class PaymentResource extends JsonResource
             'id'                          => $this->id,
             'schedule_id'                 => $this->schedule_id,
             'schedule'                    => new ScheduleResource($this->whenLoaded('schedule')),
+            'cart_id'                     => $this->cart_id,
+            'schedule_ids'                => $this->whenLoaded('cart', fn () => $this->cart?->items->pluck('schedule_id')->values()),
+            'schedules'                   => $this->whenLoaded('cart', fn () => ScheduleResource::collection(
+                $this->cart?->items->pluck('schedule')->filter()->values() ?? collect(),
+            )),
             'client_id'                   => $this->client_id,
             'provider_id'                 => $this->provider_id,
             'client_name'                 => $this->client?->user?->name,

+ 2 - 0
app/Http/Resources/PaymentSplitResource.php

@@ -27,6 +27,8 @@ class PaymentSplitResource extends JsonResource
             'metadata'                          => $this->metadata,
             'payment_status'                    => $this->payment?->status?->value,
             'payment_paid_at'                   => $this->payment?->paid_at?->toISOString(),
+            'cart_id'                           => $this->payment?->cart_id,
+            'schedule_ids'                      => $this->payment?->cart?->items?->pluck('schedule_id')->values(),
             'schedule_date'                     => $this->payment?->schedule?->date,
             'schedule_status'                   => $this->payment?->schedule?->status,
             'schedule_period_type'              => $this->payment?->schedule?->period_type,

+ 11 - 0
app/Models/Cart.php

@@ -41,6 +41,7 @@ class Cart extends Model
 
     protected $fillable = [
         'client_id',
+        'provider_id',
         'status',
     ];
 
@@ -56,6 +57,16 @@ class Cart extends Model
         return $this->belongsTo(Client::class);
     }
 
+    public function provider()
+    {
+        return $this->belongsTo(Provider::class);
+    }
+
+    public function payments()
+    {
+        return $this->hasMany(Payment::class);
+    }
+
     public function items()
     {
         return $this->hasMany(CartItem::class);

+ 6 - 0
app/Models/Payment.php

@@ -95,6 +95,7 @@ class Payment extends Model
 
     protected $fillable = [
         'schedule_id',
+        'cart_id',
         'client_id',
         'provider_id',
         'client_payment_method_id',
@@ -158,6 +159,11 @@ class Payment extends Model
         return $this->belongsTo(Schedule::class);
     }
 
+    public function cart()
+    {
+        return $this->belongsTo(Cart::class);
+    }
+
     //
 
     public function clientPaymentMethod()

+ 90 - 9
app/Services/CartItemService.php

@@ -2,8 +2,15 @@
 
 namespace App\Services;
 
+use App\Enums\CartStatusEnum;
+use App\Enums\PaymentStatusEnum;
+use App\Models\Cart;
 use App\Models\CartItem;
+use App\Models\Payment;
+use App\Models\Schedule;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
 
 class CartItemService
 {
@@ -21,20 +28,39 @@ class CartItemService
 
     public function create(array $data): CartItem
     {
-        return CartItem::create($data);
+        return DB::transaction(function () use ($data) {
+            [$cart, $schedule] = $this->validateItem($data['cart_id'], $data['schedule_id']);
+
+            if (! $cart->provider_id) {
+                $cart->update(['provider_id' => $schedule->provider_id]);
+            }
+
+            return CartItem::create($data);
+        });
     }
 
     public function update(int $id, array $data): ?CartItem
     {
-        $model = $this->findById($id);
+        return DB::transaction(function () use ($id, $data): ?CartItem {
+            $model = $this->findById($id);
 
-        if (!$model) {
-            return null;
-        }
+            if (! $model) {
+                return null;
+            }
+
+            $cartId     = $data['cart_id']     ?? $model->cart_id;
+            $scheduleId = $data['schedule_id'] ?? $model->schedule_id;
 
-        $model->update($data);
+            [$cart, $schedule] = $this->validateItem($cartId, $scheduleId);
 
-        return $model->fresh();
+            if (! $cart->provider_id) {
+                $cart->update(['provider_id' => $schedule->provider_id]);
+            }
+
+            $model->update($data);
+
+            return $model->fresh();
+        });
     }
 
     public function delete(int $id): bool
@@ -45,8 +71,63 @@ class CartItemService
             return false;
         }
 
-        return $model->delete();
+        return DB::transaction(function () use ($model): bool {
+            $this->validateItem($model->cart_id, $model->schedule_id);
+
+            return $model->delete();
+        });
     }
 
-    // Add custom business logic methods here
+    private function validateItem(int $cartId, int $scheduleId): array
+    {
+        $cart     = Cart::query()->lockForUpdate()->findOrFail($cartId);
+        $schedule = Schedule::query()->findOrFail($scheduleId);
+
+        if ($cart->status !== CartStatusEnum::OPEN) {
+            throw ValidationException::withMessages([
+                'cart_id' => 'Somente carrinhos abertos podem ser alterados.',
+            ]);
+        }
+
+        if ($schedule->client_id !== $cart->client_id) {
+            throw ValidationException::withMessages([
+                'schedule_id' => 'O agendamento precisa pertencer ao mesmo cliente do carrinho.',
+            ]);
+        }
+
+        if (! $schedule->provider_id || ($cart->provider_id && $schedule->provider_id !== $cart->provider_id)) {
+            throw ValidationException::withMessages([
+                'schedule_id' => 'Todos os agendamentos do carrinho precisam ser do mesmo prestador.',
+            ]);
+        }
+
+        $belongsToAnotherCart = CartItem::query()
+            ->where('schedule_id', $scheduleId)
+            ->where('cart_id', '!=', $cartId)
+            ->exists();
+
+        if ($belongsToAnotherCart) {
+            throw ValidationException::withMessages([
+                'schedule_id' => 'O agendamento já pertence a outro carrinho.',
+            ]);
+        }
+
+        $hasActivePayment = Payment::query()
+            ->where('schedule_id', $scheduleId)
+            ->whereIn('status', [
+                PaymentStatusEnum::PENDING->value,
+                PaymentStatusEnum::PROCESSING->value,
+                PaymentStatusEnum::AUTHORIZED->value,
+                PaymentStatusEnum::PAID->value,
+            ])
+            ->exists();
+
+        if ($hasActivePayment) {
+            throw ValidationException::withMessages([
+                'schedule_id' => 'O agendamento já possui um pagamento e não pode ser adicionado ao carrinho.',
+            ]);
+        }
+
+        return [$cart, $schedule];
+    }
 }

+ 47 - 1
app/Services/CartService.php

@@ -7,6 +7,7 @@ use App\Models\Client;
 use Illuminate\Support\Arr;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
 
 class CartService
 {
@@ -38,6 +39,30 @@ class CartService
             return null;
         }
 
+        if (isset($data['provider_id'])) {
+            $hasDifferentProvider = $model->items()
+                ->whereHas('schedule', fn ($query) => $query->where('provider_id', '!=', $data['provider_id']))
+                ->exists();
+
+            if ($hasDifferentProvider) {
+                throw ValidationException::withMessages([
+                    'provider_id' => 'O prestador não corresponde aos agendamentos existentes no carrinho.',
+                ]);
+            }
+        }
+
+        if (isset($data['client_id'])) {
+            $hasDifferentClient = $model->items()
+                ->whereHas('schedule', fn ($query) => $query->where('client_id', '!=', $data['client_id']))
+                ->exists();
+
+            if ($hasDifferentClient) {
+                throw ValidationException::withMessages([
+                    'client_id' => 'O cliente não corresponde aos agendamentos existentes no carrinho.',
+                ]);
+            }
+        }
+
         $model->update($data);
 
         return $model->fresh();
@@ -82,6 +107,7 @@ class CartService
     {
         return DB::transaction(function () use ($data) {
             $items = $this->scheduleItems($data);
+            $providerId = $this->singleProviderId($data, $items);
 
             $schedules = app(ScheduleService::class)->createSingleOrMultiple(
                 baseData:  $this->baseScheduleData($data),
@@ -89,7 +115,8 @@ class CartService
             );
 
             $cart = Cart::create([
-                'client_id' => $data['client_id'],
+                'client_id'   => $data['client_id'],
+                'provider_id' => $providerId,
             ]);
 
             foreach ($schedules as $schedule) {
@@ -146,4 +173,23 @@ class CartService
         ];
     }
 
+    private function singleProviderId(array $data, array $items): int
+    {
+        $rootProviderId = $data['provider_id'] ?? null;
+
+        $providerIds = collect($items)
+            ->map(fn (array $item) => $item['provider_id'] ?? $rootProviderId)
+            ->filter()
+            ->unique()
+            ->values();
+
+        if ($providerIds->count() !== 1) {
+            throw ValidationException::withMessages([
+                'schedules' => 'Todos os agendamentos do carrinho precisam ser do mesmo prestador.',
+            ]);
+        }
+
+        return (int) $providerIds->first();
+    }
+
 }

+ 76 - 2
app/Services/Pagarme/PagarmePaymentService.php

@@ -27,6 +27,7 @@ use App\Services\Pagarme\Concerns\FormatsPagarmeData;
 use App\Services\Pagarme\Concerns\MocksPagarmeRequests;
 use App\Services\Pagarme\Concerns\SendsPagarmeRequests;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Str;
 
 class PagarmePaymentService
@@ -160,6 +161,77 @@ class PagarmePaymentService
         );
     }
 
+    public function processCartPayment(
+        Payment $payment,
+        Collection $schedules,
+        string $paymentMethod,
+        ?string $cardId = null,
+        array $options = [],
+    ): array {
+        $firstSchedule = $schedules->first();
+
+        if (! $firstSchedule) {
+            throw new \InvalidArgumentException('Carrinho precisa ter ao menos um agendamento.');
+        }
+
+        $items = $schedules
+            ->map(fn (Schedule $schedule) => $this->buildOrderItem(
+                $schedule,
+                (float) $schedule->getAttribute('payment_gross_amount'),
+            ))
+            ->all();
+
+        $customer = $this->buildCustomer($firstSchedule, $options);
+        $split    = $this->buildSplit($payment, $options);
+        $paymentOptions = config('services.pagarme.pix_disable_split')
+            ? []
+            : ['split' => $split];
+
+        $metadata = [
+            'cart_id'      => (string) $payment->cart_id,
+            'schedule_ids' => $schedules->pluck('id')->implode(','),
+        ];
+
+        if ($paymentMethod === 'credit_card') {
+            $creditCard = new CreditCardData(
+                cardId:              $cardId,
+                installments:        $payment->installments,
+                statementDescriptor: Str::limit((string) config('app.name', 'SOFTPAR'), 13, ''),
+                operationType:       'auth_and_capture',
+            );
+
+            $result = $this->createOrderWithCreditCard(
+                payment:    $payment,
+                items:      $items,
+                customer:   $customer,
+                creditCard: $creditCard,
+                options:    ['split' => $split, 'metadata' => $metadata],
+            );
+
+            $this->logCreditCardOrderResult($payment, $result, 'create_cart_order');
+
+            return $result;
+        }
+
+        $pixData = new PixData(
+            expiresIn: 1800,
+            additionalInformation: [
+                new PixAdditionalInformationData(
+                    name:  'Agendamentos',
+                    value: $schedules->pluck('id')->implode(','),
+                ),
+            ],
+        );
+
+        return $this->createOrderWithPix(
+            payment:  $payment,
+            items:    $items,
+            customer: $customer,
+            pix:      $pixData,
+            options:  [...$paymentOptions, 'metadata' => $metadata],
+        );
+    }
+
     //
 
     public function createOrderWithCreditCard(
@@ -214,6 +286,7 @@ class PagarmePaymentService
         $metadata = array_filter([
             'payment_id'  => (string) $payment->id,
             'schedule_id' => $payment->schedule_id ? (string) $payment->schedule_id : null,
+            'cart_id'     => $payment->cart_id ? (string) $payment->cart_id : null,
             'client_id'   => (string) $payment->client_id,
             'provider_id' => $payment->provider_id ? (string) $payment->provider_id : null,
         ], fn ($value) => $value !== null);
@@ -654,12 +727,13 @@ class PagarmePaymentService
             return $payment->idempotency_key;
         }
 
-        $payment->loadMissing(['client.user', 'provider.user', 'schedule']);
+        $payment->loadMissing(['client.user', 'provider.user', 'schedule', 'cart']);
 
         $amountCents = (int) round((float) $payment->gross_amount * 100);
 
         $key = $this->pagarmeIdempotencyKey('order', [
-            "schedule-{$payment->schedule_id}",
+            "payment-{$payment->id}",
+            $payment->cart_id ? "cart-{$payment->cart_id}" : "schedule-{$payment->schedule_id}",
             $payment->client?->user?->name ?: "client-{$payment->client_id}",
             $payment->provider?->user?->name ?: ($payment->provider_id ? "provider-{$payment->provider_id}" : 'provider-none'),
             $payment->payment_method,

+ 354 - 7
app/Services/PaymentService.php

@@ -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.');
+        }
+    }
 }

+ 42 - 7
app/Services/ProviderWithdrawalService.php

@@ -68,8 +68,7 @@ class ProviderWithdrawalService
     {
         $earnings = (float) PaymentSplit::query()
             ->where('provider_id', $provider->id)
-            ->whereHas('payment', fn ($q) => $q->where('status', PaymentStatusEnum::PAID))
-            ->whereHas('payment.schedule', fn ($q) => $this->availableScheduleQuery($q))
+            ->whereHas('payment', fn ($q) => $this->availablePaymentQuery($q))
             ->sum('gross_amount');
 
         $withdrawn = (float) ProviderWithdrawal::query()
@@ -88,8 +87,7 @@ class ProviderWithdrawalService
     {
         return (float) PaymentSplit::query()
             ->where('provider_id', $provider->id)
-            ->whereHas('payment', fn ($q) => $q->where('status', PaymentStatusEnum::PAID))
-            ->whereHas('payment.schedule', fn ($q) => $this->pendingScheduleQuery($q))
+            ->whereHas('payment', fn ($q) => $this->pendingPaymentQuery($q))
             ->sum('gross_amount');
     }
 
@@ -99,7 +97,7 @@ class ProviderWithdrawalService
     {
         return PaymentSplit::query()
             ->where('provider_id', $provider->id)
-            ->with(['payment.schedule.client.user'])
+            ->with(['payment.schedule.client.user', 'payment.cart.items.schedule.client.user'])
             ->orderBy('created_at', 'desc')
             ->get();
     }
@@ -185,8 +183,7 @@ class ProviderWithdrawalService
                 PaymentSplit::query()
                     ->where('provider_id', $provider->id)
                     ->whereNull('provider_withdrawal_id')
-                    ->whereHas('payment', fn ($q) => $q->where('status', PaymentStatusEnum::PAID))
-                    ->whereHas('payment.schedule', fn ($q) => $this->availableScheduleQuery($q))
+                    ->whereHas('payment', fn ($q) => $this->availablePaymentQuery($q))
                     ->update(['provider_withdrawal_id' => $withdrawal->id]);
 
                 return $withdrawal->fresh();
@@ -257,6 +254,44 @@ class ProviderWithdrawalService
             );
     }
 
+    private function unavailableScheduleQuery($query)
+    {
+        return $query->where(function ($query) {
+            $query->where('code_verified', false)
+                ->when(
+                    ! app()->environment('local', 'development'),
+                    fn ($query) => $query->orWhereRaw(
+                        '(date + end_time) > ?',
+                        [$this->withdrawalReleaseCutoff()]
+                    )
+                );
+        });
+    }
+
+    private function availablePaymentQuery($query)
+    {
+        return $query
+            ->where('status', PaymentStatusEnum::PAID)
+            ->where(function ($query) {
+                $query->whereHas('schedule', fn ($query) => $this->availableScheduleQuery($query))
+                    ->orWhere(function ($query) {
+                        $query->whereNotNull('cart_id')
+                            ->whereHas('cart.items')
+                            ->whereDoesntHave('cart.items.schedule', fn ($query) => $this->unavailableScheduleQuery($query));
+                    });
+            });
+    }
+
+    private function pendingPaymentQuery($query)
+    {
+        return $query
+            ->where('status', PaymentStatusEnum::PAID)
+            ->where(function ($query) {
+                $query->whereHas('schedule', fn ($query) => $this->pendingScheduleQuery($query))
+                    ->orWhereHas('cart.items.schedule', fn ($query) => $this->pendingScheduleQuery($query));
+            });
+    }
+
     private function pendingScheduleQuery($query)
     {
         return $query

+ 1 - 5
app/Services/WebhookService.php

@@ -94,11 +94,7 @@ class WebhookService
         try {
             $payment = $this->pagarmePaymentService->applyGatewayResponseToPayment($payment, $orderResponse);
 
-            $payment->loadMissing('schedule');
-
-            if ($payment->schedule) {
-                $this->paymentService->syncScheduleStatusAfterPayment($payment->schedule, $payment);
-            }
+            $this->paymentService->syncPaymentTargets($payment);
 
             $webhook->update([
                 'payment_id'   => $payment->id,

+ 90 - 0
database/migrations/2026_07_20_170000_add_provider_to_carts_and_cart_to_payments.php

@@ -0,0 +1,90 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('carts', function (Blueprint $table) {
+            $table->foreignId('provider_id')
+                ->nullable()
+                ->after('client_id')
+                ->constrained('providers')
+                ->restrictOnDelete();
+        });
+
+        DB::statement(<<<'SQL'
+            UPDATE carts
+            SET provider_id = homogeneous.provider_id
+            FROM (
+                SELECT cart_items.cart_id, MIN(schedules.provider_id) AS provider_id
+                FROM cart_items
+                INNER JOIN schedules ON schedules.id = cart_items.schedule_id
+                WHERE schedules.provider_id IS NOT NULL
+                GROUP BY cart_items.cart_id
+                HAVING COUNT(DISTINCT schedules.provider_id) = 1
+                   AND COUNT(*) = COUNT(schedules.provider_id)
+            ) AS homogeneous
+            WHERE carts.id = homogeneous.cart_id
+        SQL);
+
+        Schema::table('payments', function (Blueprint $table) {
+            $table->foreignId('schedule_id')->nullable()->change();
+            $table->foreignId('cart_id')
+                ->nullable()
+                ->after('schedule_id')
+                ->constrained('carts')
+                ->restrictOnDelete();
+        });
+
+        DB::statement(<<<'SQL'
+            ALTER TABLE payments
+            ADD CONSTRAINT payments_exactly_one_payable_check
+            CHECK (num_nonnulls(schedule_id, cart_id) = 1)
+        SQL);
+
+        DB::statement(<<<'SQL'
+            CREATE UNIQUE INDEX payments_one_active_per_cart
+            ON payments (cart_id)
+            WHERE cart_id IS NOT NULL
+              AND deleted_at IS NULL
+              AND status IN ('pending', 'processing', 'authorized', 'paid')
+        SQL);
+
+        DB::statement(<<<'SQL'
+            CREATE UNIQUE INDEX cart_items_one_cart_per_schedule
+            ON cart_items (schedule_id)
+        SQL);
+    }
+
+    public function down(): void
+    {
+        DB::statement('DROP INDEX IF EXISTS payments_one_active_per_cart');
+        DB::statement('DROP INDEX IF EXISTS cart_items_one_cart_per_schedule');
+        DB::statement('ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_exactly_one_payable_check');
+        DB::statement(<<<'SQL'
+            UPDATE payments
+            SET schedule_id = first_item.schedule_id
+            FROM (
+                SELECT cart_id, MIN(schedule_id) AS schedule_id
+                FROM cart_items
+                GROUP BY cart_id
+            ) AS first_item
+            WHERE payments.cart_id = first_item.cart_id
+              AND payments.schedule_id IS NULL
+        SQL);
+
+        Schema::table('payments', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('cart_id');
+            $table->foreignId('schedule_id')->nullable(false)->change();
+        });
+
+        Schema::table('carts', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('provider_id');
+        });
+    }
+};

+ 1 - 0
routes/authRoutes/payment.php

@@ -7,6 +7,7 @@ Route::get('/payment',  [PaymentController::class, 'index'])->middleware('permis
 Route::post('/payment', [PaymentController::class, 'store'])->middleware('permission:payment,add');
 
 Route::get('/payment/platform-fees',            [PaymentController::class, 'platformFees']);
+Route::post('/payment/cart/{cart}/pay',          [PaymentController::class, 'payCart'])->middleware('permission:config.schedule,edit');
 Route::get('/payment/schedule/{schedule}/pix',  [PaymentController::class, 'getSchedulePix'])->middleware('permission:config.schedule,view');
 Route::post('/payment/schedule/{schedule}/pay', [PaymentController::class, 'paySchedule'])->middleware('permission:config.schedule,edit');
 

+ 135 - 0
tests/Unit/CartProviderInvariantTest.php

@@ -0,0 +1,135 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Enums\CartStatusEnum;
+use App\Http\Requests\CartRequest;
+use App\Models\Cart;
+use App\Models\Provider;
+use App\Models\Schedule;
+use App\Services\CartService;
+use App\Services\Pagarme\PagarmePaymentService;
+use App\Services\PaymentService;
+use Illuminate\Validation\ValidationException;
+use Tests\TestCase;
+
+class CartProviderInvariantTest extends TestCase
+{
+    public function test_request_rejects_schedules_from_different_providers(): void
+    {
+        $request = CartRequest::create('/cart/me', 'POST', [
+            'schedules' => [
+                ['provider_id' => 10],
+                ['provider_id' => 20],
+            ],
+        ]);
+
+        $validator = validator([], []);
+
+        foreach ($request->after() as $callback) {
+            $callback($validator);
+        }
+
+        $this->assertSame(
+            'Todos os agendamentos do carrinho precisam ser do mesmo prestador.',
+            $validator->errors()->first('schedules'),
+        );
+    }
+
+    public function test_request_accepts_schedules_from_the_same_provider(): void
+    {
+        $request = CartRequest::create('/cart/me', 'POST', [
+            'provider_id' => 10,
+            'schedules'   => [
+                ['date' => '2026-08-01'],
+                ['provider_id' => 10, 'date' => '2026-08-02'],
+            ],
+        ]);
+
+        $validator = validator([], []);
+
+        foreach ($request->after() as $callback) {
+            $callback($validator);
+        }
+
+        $this->assertFalse($validator->errors()->has('schedules'));
+    }
+
+    public function test_service_defensively_rejects_mixed_providers(): void
+    {
+        $method = new \ReflectionMethod(CartService::class, 'singleProviderId');
+
+        $this->expectException(ValidationException::class);
+
+        $method->invoke(new CartService, [], [
+            ['provider_id' => 10],
+            ['provider_id' => 20],
+        ]);
+    }
+
+    public function test_checkout_rejects_a_cart_with_a_schedule_from_another_provider(): void
+    {
+        $cart = $this->cart(providerId: 10);
+        $schedules = collect([
+            $this->schedule(id: 1, providerId: 10),
+            $this->schedule(id: 2, providerId: 20),
+        ]);
+
+        $method = new \ReflectionMethod(PaymentService::class, 'validateCartForPayment');
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('Todos os agendamentos do carrinho precisam ser do mesmo prestador.');
+
+        $method->invoke(new PaymentService(new PagarmePaymentService), $cart, $schedules);
+    }
+
+    public function test_checkout_waits_until_every_schedule_is_accepted(): void
+    {
+        $cart = $this->cart(providerId: 10);
+        $schedules = collect([
+            $this->schedule(id: 1, providerId: 10),
+            $this->schedule(id: 2, providerId: 10, status: 'pending'),
+        ]);
+
+        $method = new \ReflectionMethod(PaymentService::class, 'validateCartForPayment');
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('Agendamento 2 precisa estar aceito para o carrinho ser pago.');
+
+        $method->invoke(new PaymentService(new PagarmePaymentService), $cart, $schedules);
+    }
+
+    private function cart(int $providerId): Cart
+    {
+        $provider = new Provider;
+        $provider->forceFill([
+            'id'           => $providerId,
+            'recipient_id' => 'rp_test',
+        ]);
+
+        $cart = new Cart;
+        $cart->forceFill([
+            'id'          => 1,
+            'client_id'   => 30,
+            'provider_id' => $providerId,
+            'status'      => CartStatusEnum::OPEN->value,
+        ]);
+        $cart->setRelation('provider', $provider);
+
+        return $cart;
+    }
+
+    private function schedule(int $id, int $providerId, string $status = 'accepted'): Schedule
+    {
+        $schedule = new Schedule;
+        $schedule->forceFill([
+            'id'           => $id,
+            'client_id'    => 30,
+            'provider_id'  => $providerId,
+            'status'       => $status,
+            'total_amount' => 100,
+        ]);
+
+        return $schedule;
+    }
+}