Quellcode durchsuchen

feat: add opção de fazer compra em conjunto de reservas

Gustavo Mantovani vor 1 Monat
Ursprung
Commit
e1d298e0e2

+ 57 - 25
app/Http/Controllers/PaymentController.php

@@ -31,10 +31,33 @@ class PaymentController extends Controller
         return $this->successResponse(
             payload: new PaymentResource($item),
             message: $this->paymentMessage($item->status->value),
-            code: 201,
+            code:    201,
         );
     }
 
+    public function show(int $id): JsonResponse
+    {
+        $item = $this->service->findById($id);
+
+        return $this->successResponse(payload: new PaymentResource($item));
+    }
+
+    public function update(PaymentRequest $request, int $id): JsonResponse
+    {
+        $item = $this->service->update($id, $request->validated());
+
+        return $this->successResponse(payload: new PaymentResource($item), message: __('messages.updated'));
+    }
+
+    public function destroy(int $id): JsonResponse
+    {
+        $this->service->delete($id);
+
+        return $this->successResponse(message: __('messages.deleted'), code: 204);
+    }
+
+    //
+
     public function paySchedule(Request $request, Schedule $schedule): JsonResponse
     {
         $validated = $request->validate([
@@ -49,11 +72,12 @@ class PaymentController extends Controller
         }
 
         $item = $this->service->payAcceptedSchedule(
-            schedule: $schedule,
-            paymentMethod: $validated['payment_method'],
+            schedule:              $schedule,
+            paymentMethod:         $validated['payment_method'],
             clientPaymentMethodId: $validated['client_payment_method_id'] ?? null,
+
             options: [
-                'phone'   => $validated['phone'] ?? null,
+                'phone'   => $validated['phone']   ?? null,
                 'card_id' => $validated['card_id'] ?? null,
             ],
         );
@@ -68,7 +92,34 @@ class PaymentController extends Controller
         return $this->successResponse(
             payload: new PaymentResource($item),
             message: $this->paymentMessage($item->status->value),
-            code: 201,
+            code:    201,
+        );
+    }
+
+    public function payCart(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'schedule_ids'             => ['required', 'array', 'min:1'],
+            'schedule_ids.*'           => ['required', 'integer', 'distinct', 'exists:schedules,id'],
+            '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'],
+        ]);
+
+        $item = $this->service->payCart($validated, (int) Auth::id());
+
+        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,
         );
     }
 
@@ -88,26 +139,7 @@ class PaymentController extends Controller
         return $this->successResponse(payload: new PaymentResource($item));
     }
 
-    public function show(int $id): JsonResponse
-    {
-        $item = $this->service->findById($id);
-
-        return $this->successResponse(payload: new PaymentResource($item));
-    }
-
-    public function update(PaymentRequest $request, int $id): JsonResponse
-    {
-        $item = $this->service->update($id, $request->validated());
-
-        return $this->successResponse(payload: new PaymentResource($item), message: __('messages.updated'));
-    }
-
-    public function destroy(int $id): JsonResponse
-    {
-        $this->service->delete($id);
-
-        return $this->successResponse(message: __('messages.deleted'), code: 204);
-    }
+    //
 
     private function paymentMessage(string $status): string
     {

+ 28 - 8
app/Http/Controllers/ScheduleController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Http\Requests\ScheduleRequest;
 use App\Http\Resources\ScheduleResource;
+use App\Services\ScheduleCartService;
 use App\Services\ScheduleService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
@@ -36,7 +37,7 @@ class ScheduleController extends Controller
             return $this->successResponse(
                 payload: ScheduleResource::collection($schedules),
                 message: count($schedules).' '.__('schedules.schedules_created'),
-                code: 201,
+                code:    201,
             );
         } catch (\Exception $e) {
             return $this->errorResponse($e->getMessage(), 422);
@@ -72,16 +73,11 @@ class ScheduleController extends Controller
 
         return $this->successResponse(
             message: __('messages.deleted'),
-            code: 204,
+            code:    204,
         );
     }
 
-    public function groupedByClient(): JsonResponse
-    {
-        $grouped = $this->scheduleService->getSchedulesDefaultGroupedByClient();
-
-        return $this->successResponse($grouped);
-    }
+    //
 
     public function finished(): JsonResponse
     {
@@ -92,6 +88,13 @@ class ScheduleController extends Controller
         );
     }
 
+    public function groupedByClient(): JsonResponse
+    {
+        $grouped = $this->scheduleService->getSchedulesDefaultGroupedByClient();
+
+        return $this->successResponse($grouped);
+    }
+
     public function updateStatus(string $id, ScheduleRequest $request): JsonResponse
     {
         try {
@@ -110,6 +113,8 @@ class ScheduleController extends Controller
         }
     }
 
+    //
+
     public function cancelWithReason(string $id, Request $request): JsonResponse
     {
         try {
@@ -142,4 +147,19 @@ class ScheduleController extends Controller
 
         return $this->successResponse(payload: $blocks);
     }
+
+    public function storeCart(ScheduleRequest $request, ScheduleCartService $cartService): JsonResponse
+    {
+        try {
+            $schedules = $cartService->create($request->validated());
+
+            return $this->successResponse(
+                payload: ScheduleResource::collection($schedules),
+                message: count($schedules).' '.__('schedules.schedules_created'),
+                code:    201,
+            );
+        } catch (\Exception $e) {
+            return $this->errorResponse($e->getMessage(), 422);
+        }
+    }
 }

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

@@ -19,6 +19,8 @@ class PaymentResource extends JsonResource
         return [
             'id'                          => $this->id,
             'schedule_id'                 => $this->schedule_id,
+            'schedule_ids'                => $this->whenLoaded('schedules', fn () => $this->schedules->pluck('id')->values()),
+            'schedules'                   => $this->whenLoaded('schedules', fn () => ScheduleResource::collection($this->schedules)),
             'client_id'                   => $this->client_id,
             'provider_id'                 => $this->provider_id,
             'client_name'                 => $this->client?->user?->name,
@@ -68,6 +70,7 @@ class PaymentResource extends JsonResource
         }
 
         $charge = $this->gateway_payload['charges'][0] ?? [];
+
         $transaction = $charge['last_transaction'] ?? [];
 
         $expiresAt = $charge['expires_at']

+ 26 - 26
app/Models/Address.php

@@ -31,32 +31,32 @@ use Illuminate\Support\Facades\DB;
  * @property float|null $longitude
  * @property-read \App\Models\City|null $city
  * @property-read \App\Models\State|null $state
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address newModelQuery()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address newQuery()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address onlyTrashed()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address query()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereAddress($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereAddressType($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereCityId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereComplement($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereCreatedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereDeletedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereDistrict($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereHasComplement($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereInstructions($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereIsPrimary($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereLatitude($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereLongitude($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereNickname($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereNumber($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereSource($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereSourceId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereStateId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereUpdatedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereZipCode($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address withTrashed(bool $withTrashed = true)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Address withoutTrashed()
+ * @method static Builder<static>|Address newModelQuery()
+ * @method static Builder<static>|Address newQuery()
+ * @method static Builder<static>|Address onlyTrashed()
+ * @method static Builder<static>|Address query()
+ * @method static Builder<static>|Address whereAddress($value)
+ * @method static Builder<static>|Address whereAddressType($value)
+ * @method static Builder<static>|Address whereCityId($value)
+ * @method static Builder<static>|Address whereComplement($value)
+ * @method static Builder<static>|Address whereCreatedAt($value)
+ * @method static Builder<static>|Address whereDeletedAt($value)
+ * @method static Builder<static>|Address whereDistrict($value)
+ * @method static Builder<static>|Address whereHasComplement($value)
+ * @method static Builder<static>|Address whereId($value)
+ * @method static Builder<static>|Address whereInstructions($value)
+ * @method static Builder<static>|Address whereIsPrimary($value)
+ * @method static Builder<static>|Address whereLatitude($value)
+ * @method static Builder<static>|Address whereLongitude($value)
+ * @method static Builder<static>|Address whereNickname($value)
+ * @method static Builder<static>|Address whereNumber($value)
+ * @method static Builder<static>|Address whereSource($value)
+ * @method static Builder<static>|Address whereSourceId($value)
+ * @method static Builder<static>|Address whereStateId($value)
+ * @method static Builder<static>|Address whereUpdatedAt($value)
+ * @method static Builder<static>|Address whereZipCode($value)
+ * @method static Builder<static>|Address withTrashed(bool $withTrashed = true)
+ * @method static Builder<static>|Address withoutTrashed()
  * @mixin \Eloquent
  */
 class Address extends Model

+ 2 - 2
app/Models/Client.php

@@ -38,8 +38,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereCreatedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereDeletedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereDocument($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereExternalCustomerCode($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereExternalCustomerId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereGatewayCustomerCode($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereGatewayCustomerId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereIdempotencyKey($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Client whereProfileMediaId($value)

+ 8 - 0
app/Models/Payment.php

@@ -43,6 +43,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property-read \App\Models\ClientPaymentMethod|null $clientPaymentMethod
  * @property-read \App\Models\Provider|null $provider
  * @property-read \App\Models\Schedule $schedule
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Schedule> $schedules
+ * @property-read int|null $schedules_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\PaymentSplit> $splits
  * @property-read int|null $splits_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Webhook> $webhooks
@@ -148,6 +150,12 @@ class Payment extends Model
         return $this->belongsTo(Schedule::class);
     }
 
+    public function schedules()
+    {
+        return $this->belongsToMany(Schedule::class, 'payment_schedules')
+            ->withTimestamps();
+    }
+
     public function client()
     {
         return $this->belongsTo(Client::class);

+ 42 - 0
app/Models/PaymentSchedule.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property-read \App\Models\Payment|null $payment
+ * @property-read \App\Models\Schedule|null $schedule
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PaymentSchedule newModelQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PaymentSchedule newQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PaymentSchedule query()
+ * @mixin \Eloquent
+ */
+class PaymentSchedule extends Model
+{
+    use HasFactory;
+
+    protected $table = 'payment_schedules';
+
+    protected $fillable = [
+        'payment_id',
+        'schedule_id',
+    ];
+
+    protected $casts = [
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function payment(): BelongsTo
+    {
+        return $this->belongsTo(Payment::class);
+    }
+
+    public function schedule(): BelongsTo
+    {
+        return $this->belongsTo(Schedule::class);
+    }
+}

+ 93 - 6
app/Services/Pagarme/PagarmePaymentService.php

@@ -24,6 +24,7 @@ use App\Models\PaymentSplit;
 use App\Models\Schedule;
 use App\Services\Pagarme\Concerns\FormatsPagarmeData;
 use App\Services\Pagarme\Concerns\SendsPagarmeRequests;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 
@@ -134,6 +135,85 @@ 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: $schedule,
+                grossAmount: (float) data_get($schedule, 'payment_gross_amount', $schedule->total_amount),
+            ))
+            ->values()
+            ->all();
+
+        $customer = $this->buildCustomer($firstSchedule, $options);
+        $split    = $this->buildSplit($payment, $options);
+
+        $metadataOptions = [
+            'metadata' => [
+                'schedule_ids' => $schedules->pluck('id')->implode(','),
+            ],
+        ];
+
+        $splitOptions = ['split' => $split];
+
+        $pixOptions = config('services.pagarme.pix_disable_split')
+            ? $metadataOptions
+            : array_merge($metadataOptions, $splitOptions);
+
+        $creditCardOptions = array_merge($metadataOptions, $splitOptions);
+
+        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:    $creditCardOptions,
+            );
+
+            $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:  $pixOptions,
+        );
+    }
+
     public function createOrderWithCreditCard(
         Payment             $payment,
         array               $items,
@@ -183,12 +263,14 @@ class PagarmePaymentService
         PaymentData         $paymentMethod,
         array               $options = []
     ): array {
-        $metadata = array_merge([
+        $metadata = array_filter([
             'payment_id'  => (string) $payment->id,
-            'schedule_id' => (string) $payment->schedule_id,
+            'schedule_id' => $payment->schedule_id ? (string) $payment->schedule_id : null,
             'client_id'   => (string) $payment->client_id,
-            'provider_id' => (string) $payment->provider_id,
-        ], $options['metadata'] ?? []);
+            'provider_id' => $payment->provider_id ? (string) $payment->provider_id : null,
+        ], fn ($value) => $value !== null);
+
+        $metadata = array_merge($metadata, $options['metadata'] ?? []);
 
         $requestData = new OrderRequestData(
             code:       $payment->ensureGatewayCode(),
@@ -356,16 +438,21 @@ class PagarmePaymentService
     }
 
     private function buildOrderItems(Schedule $schedule, float $grossAmount): array
+    {
+        return [$this->buildOrderItem($schedule, $grossAmount)];
+    }
+
+    private function buildOrderItem(Schedule $schedule, float $grossAmount): ItemData
     {
         $description = $schedule->customSchedule?->serviceType?->description
             ?? "Servico {$schedule->id}";
 
-        return [new ItemData(
+        return new ItemData(
             code:        "schedule-{$schedule->id}",
             amount:      OrderRequestData::amountInCents($grossAmount),
             quantity:    1,
             description: $description,
-        )];
+        );
     }
 
     private function logCreditCardOrderResult(Payment $payment, array $orderResponse, string $source): void

+ 280 - 10
app/Services/PaymentService.php

@@ -10,7 +10,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
@@ -63,6 +66,8 @@ class PaymentService
         return $model->delete();
     }
 
+    //
+
     public function platformFees(): array
     {
         return $this->pagarmePaymentService->platformFeeRates();
@@ -72,9 +77,8 @@ class PaymentService
 
     public function payAcceptedSchedule(
         Schedule $schedule,
-        string $paymentMethod,
-        ?int $clientPaymentMethodId = null,
-        array $options = []
+        string   $paymentMethod,
+        ?int     $clientPaymentMethodId = null, array $options = []
     ): Payment {
         $schedule->loadMissing(['client', 'provider', 'customSchedule.serviceType']);
 
@@ -116,7 +120,9 @@ class PaymentService
                     'failed_at'       => now(),
                     'failure_message' => 'Pagamento pendente sem retorno do gateway.',
                 ])->save();
-            } elseif ($this->isExpiredPixPayment($existingPayment)) {
+            }
+
+            elseif ($this->isExpiredPixPayment($existingPayment)) {
                 $existingPayment->forceFill([
                     'status'          => PaymentStatusEnum::FAILED,
                     'failed_at'       => now(),
@@ -126,7 +132,9 @@ class PaymentService
                 PaymentSplit::query()
                     ->where('payment_id', $existingPayment->id)
                     ->update(['status' => PaymentSplitStatusEnum::FAILED]);
-            } else {
+            }
+
+            else {
                 if ($existingPayment->payment_method !== $paymentMethod && $existingPayment->status !== PaymentStatusEnum::PAID) {
                     throw new \InvalidArgumentException('Ja existe um pagamento em andamento para este agendamento.');
                 }
@@ -215,11 +223,11 @@ class PaymentService
 
         try {
             $orderResponse = $this->pagarmePaymentService->processPayment(
-                payment: $payment,
-                schedule: $schedule,
+                payment:       $payment,
+                schedule:      $schedule,
                 paymentMethod: $paymentMethod,
-                cardId: $cardId,
-                options: $options,
+                cardId:        $cardId,
+                options:       $options,
             );
         } catch (\Throwable $e) {
             $payment->forceFill([
@@ -242,6 +250,160 @@ class PaymentService
         return $payment;
     }
 
+    public function payAcceptedSchedules(
+        array|SupportCollection $schedules, string $paymentMethod,
+        ?int $clientPaymentMethodId = null, array $options = []
+    ): Payment {
+        $schedules = collect($schedules)
+            ->map(fn (Schedule|int $schedule) => $schedule instanceof Schedule ? $schedule : Schedule::findOrFail($schedule))
+            ->values();
+
+        $this->validateCartSchedules($schedules, $paymentMethod);
+
+        $firstSchedule = $schedules->first();
+
+        if (! $firstSchedule) {
+            throw new \InvalidArgumentException('Carrinho precisa ter ao menos um agendamento.');
+        }
+
+        $clientPaymentMethod = null;
+
+        $cardId = null;
+
+        if ($paymentMethod === 'credit_card') {
+            if (! $clientPaymentMethodId && empty($options['card_id'])) {
+                throw new \InvalidArgumentException('Cartao de pagamento ou card_id e obrigatorio.');
+            }
+
+            if ($clientPaymentMethodId) {
+                $clientPaymentMethod = ClientPaymentMethod::query()
+                    ->where('client_id', $firstSchedule->client_id)
+                    ->where('id', $clientPaymentMethodId)
+                    ->where('is_active', true)
+                    ->first();
+
+                if (! $clientPaymentMethod) {
+                    throw new \InvalidArgumentException('Cartao de pagamento nao encontrado ou inativo para este cliente.');
+                }
+            }
+
+            $cardId = $options['card_id'] ?? $clientPaymentMethod?->gateway_card_id ?? null;
+
+            if (empty($cardId)) {
+                throw new \InvalidArgumentException('Cartao de pagamento invalido ou sem gateway_card_id do Pagar.me.');
+            }
+        }
+
+        $payment = DB::transaction(function () use ($schedules, $paymentMethod, $clientPaymentMethod, $firstSchedule) {
+            $totals = $this->cartPaymentTotals($schedules, $paymentMethod);
+
+            $providerIds = $schedules->pluck('provider_id')->unique()->values();
+
+            $payment = Payment::create([
+                'schedule_id'              => null,
+                'client_id'                => $firstSchedule->client_id,
+                'provider_id'              => $providerIds->count() === 1 ? $providerIds->first() : null,
+                'client_payment_method_id' => $paymentMethod === 'credit_card' ? ($clientPaymentMethod?->id ?? null) : 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' => [
+                    'service_amount' => number_format($totals['service_amount'], 2, '.', ''),
+                    'platform_fee'   => number_format($totals['platform_fee_amount'], 2, '.', ''),
+                    'schedule_ids'   => $schedules->pluck('id')->map(fn ($id) => (string) $id)->all(),
+                ],
+            ]);
+
+            foreach ($schedules as $schedule) {
+                $amounts = $this->pagarmePaymentService->calculatePaymentAmounts(
+                    serviceAmount: (float) $schedule->total_amount,
+                    paymentMethod: $paymentMethod,
+                );
+
+                $schedule->setAttribute('payment_gross_amount', $amounts['gross_amount']);
+
+                $payment->schedules()->attach($schedule->id);
+
+                PaymentSplit::create([
+                    'payment_id'                        => $payment->id,
+                    'provider_id'                       => $schedule->provider_id,
+                    'gateway_provider'                  => 'pagarme',
+                    'gateway_transfer_target_reference' => $schedule->provider->recipient_id,
+                    'gateway_transfer_target_label'     => 'recipient',
+                    'status'                            => PaymentSplitStatusEnum::PENDING,
+                    'gross_amount'                      => (float) $schedule->total_amount,
+                    'gateway_fee_amount'                => 0,
+                    'net_amount'                        => (float) $schedule->total_amount,
+
+                    'metadata' => [
+                        'schedule_id' => (string) $schedule->id,
+                    ],
+                ]);
+            }
+
+            return $payment;
+        });
+
+        $firstSchedule->ensureCustomerPhone($options['phone'] ?? null);
+
+        try {
+            $orderResponse = $this->pagarmePaymentService->processCartPayment(
+                payment:       $payment,
+                schedules:     $schedules,
+                paymentMethod: $paymentMethod,
+                cardId:        $cardId,
+                options:       $options,
+            );
+        } catch (\Throwable $e) {
+            $payment->forceFill([
+                'status'          => PaymentStatusEnum::FAILED,
+                'failed_at'       => now(),
+                'failure_message' => $e->getMessage(),
+            ])->save();
+
+            PaymentSplit::query()
+                ->where('payment_id', $payment->id)
+                ->update(['status' => PaymentSplitStatusEnum::FAILED]);
+
+            throw $e;
+        }
+
+        $payment = $this->pagarmePaymentService->applyGatewayResponseToPayment($payment, $orderResponse);
+
+        $this->syncSchedulesStatusAfterPayment($payment);
+
+        return $payment->fresh(['client.user', 'provider.user', 'schedules']);
+    }
+
+    public function payCart(array $data, int $userId): Payment
+    {
+        $schedules = $this->cartSchedules($data['schedule_ids']);
+
+        if ($schedules->contains(fn (Schedule $schedule) => $schedule->client?->user_id !== $userId)) {
+            throw new AuthorizationException;
+        }
+
+        return $this->payAcceptedSchedules(
+            schedules:             $schedules,
+            paymentMethod:         $data['payment_method'],
+            clientPaymentMethodId: $data['client_payment_method_id'] ?? null,
+
+            options: [
+                'phone'   => $data['phone'] ?? null,
+                'card_id' => $data['card_id'] ?? null,
+            ],
+        );
+    }
+
     public function getOrCreatePixPayment(Schedule $schedule): Payment
     {
         $existingPayment = Payment::query()
@@ -289,7 +451,7 @@ class PaymentService
         }
 
         return $this->payAcceptedSchedule(
-            schedule: $schedule,
+            schedule:      $schedule,
             paymentMethod: 'pix',
         );
     }
@@ -326,4 +488,112 @@ class PaymentService
 
         $schedule->update(['status' => 'paid']);
     }
+
+    public function syncSchedulesStatusAfterPayment(Payment $payment): void
+    {
+        $payment->loadMissing('schedules');
+
+        if ($payment->schedules->isEmpty()) {
+            if ($payment->schedule) {
+                $this->syncScheduleStatusAfterPayment($payment->schedule, $payment);
+            }
+
+            return;
+        }
+
+        $payment->schedules->each(fn (Schedule $schedule) => $this->syncScheduleStatusAfterPayment($schedule, $payment));
+    }
+
+    private function validateCartSchedules(SupportCollection $schedules, string $paymentMethod): void
+    {
+        if ($schedules->isEmpty()) {
+            throw new \InvalidArgumentException('Carrinho precisa ter ao menos um agendamento.');
+        }
+
+        if (! in_array($paymentMethod, ['credit_card', 'pix'], true)) {
+            throw new \InvalidArgumentException('Forma de pagamento invalida.');
+        }
+
+        $clientIds = $schedules->pluck('client_id')->unique()->values();
+
+        if ($clientIds->count() !== 1) {
+            throw new \InvalidArgumentException('Todos os agendamentos do carrinho precisam ser do mesmo cliente.');
+        }
+
+        $schedules->each(function (Schedule $schedule): void {
+            $schedule->loadMissing(['client', 'provider', 'customSchedule.serviceType']);
+
+            if ($schedule->status !== 'accepted') {
+                throw new \InvalidArgumentException("Agendamento {$schedule->id} precisa estar aceito para ser pago.");
+            }
+
+            if (! $schedule->provider_id || ! $schedule->provider) {
+                throw new \InvalidArgumentException("Agendamento {$schedule->id} precisa ter prestador confirmado para gerar pagamento.");
+            }
+
+            if ((float) $schedule->total_amount <= 0) {
+                throw new \InvalidArgumentException("Agendamento {$schedule->id} precisa ter valor maior que zero para gerar pagamento.");
+            }
+
+            if (empty($schedule->provider->recipient_id)) {
+                throw new \InvalidArgumentException("Prestador do agendamento {$schedule->id} precisa ter recipient_id do Pagar.me para receber split.");
+            }
+
+            $existingPayment = Payment::query()
+                ->where(function ($query) use ($schedule) {
+                    $query->where('schedule_id', $schedule->id)
+                        ->orWhereHas('schedules', fn ($query) => $query->where('schedules.id', $schedule->id));
+                })
+                ->whereIn('status', [
+                    PaymentStatusEnum::PENDING->value,
+                    PaymentStatusEnum::PROCESSING->value,
+                    PaymentStatusEnum::AUTHORIZED->value,
+                    PaymentStatusEnum::PAID->value,
+                ])
+                ->latest('id')
+                ->first();
+
+            if ($existingPayment) {
+                throw new \InvalidArgumentException("Ja existe um pagamento em andamento para o agendamento {$schedule->id}.");
+            }
+        });
+    }
+
+    //
+
+    private function cartSchedules(array $scheduleIds): SupportCollection
+    {
+        $schedules = Schedule::query()
+            ->with(['client', 'provider', 'customSchedule.serviceType'])
+            ->whereIn('id', $scheduleIds)
+            ->get()
+            ->sortBy(fn (Schedule $schedule) => array_search($schedule->id, $scheduleIds, true))
+            ->values();
+
+        if ($schedules->count() !== count($scheduleIds)) {
+            throw new \InvalidArgumentException('Um ou mais agendamentos nao foram encontrados.');
+        }
+
+        return $schedules;
+    }
+
+    private function cartPaymentTotals(SupportCollection $schedules, string $paymentMethod): array
+    {
+        return $schedules->reduce(function (array $totals, Schedule $schedule) use ($paymentMethod) {
+            $amounts = $this->pagarmePaymentService->calculatePaymentAmounts(
+                serviceAmount: (float) $schedule->total_amount,
+                paymentMethod: $paymentMethod,
+            );
+
+            return [
+                'service_amount'      => $totals['service_amount'] + $amounts['service_amount'],
+                'platform_fee_amount' => $totals['platform_fee_amount'] + $amounts['platform_fee_amount'],
+                'gross_amount'        => $totals['gross_amount'] + $amounts['gross_amount'],
+            ];
+        }, [
+            'service_amount'      => 0,
+            'platform_fee_amount' => 0,
+            'gross_amount'        => 0,
+        ]);
+    }
 }

+ 55 - 0
app/Services/ScheduleCartService.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Arr;
+
+class ScheduleCartService
+{
+    public function __construct(
+        private readonly ScheduleService $scheduleService,
+    ) {}
+
+    public function create(array $data): array
+    {
+        return $this->scheduleService->createSingleOrMultiple(
+            baseData:  $this->baseScheduleData($data),
+            schedules: $this->scheduleItems($data),
+        );
+    }
+
+    private function baseScheduleData(array $data): array
+    {
+        return Arr::except($data, [
+            'schedules',
+            'dates',
+            'date',
+            'period_type',
+            'start_time',
+            'end_time',
+            'total_amount',
+            'offers_meal',
+        ]);
+    }
+
+    private function scheduleItems(array $data): array
+    {
+        if (! empty($data['schedules'])) {
+            return $data['schedules'];
+        }
+
+        if (! empty($data['dates'])) {
+            return array_map(
+                fn (string $date) => array_merge(
+                    Arr::only($data, ['period_type', 'start_time', 'end_time', 'total_amount', 'offers_meal']),
+                    ['date' => $date],
+                ),
+                $data['dates'],
+            );
+        }
+
+        return [
+            Arr::only($data, ['date', 'period_type', 'start_time', 'end_time', 'total_amount', 'offers_meal']),
+        ];
+    }
+}

+ 1 - 1
app/Services/WebhookService.php

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

+ 26 - 0
database/migrations/2026_06_18_132629_create_payment_schedules_table.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('payment_schedules', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('payment_id')->constrained('payments')->onDelete('cascade');
+            $table->foreignId('schedule_id')->constrained('schedules')->onDelete('cascade');
+            $table->timestamps();
+
+            $table->unique(['payment_id', 'schedule_id']);
+            $table->index('schedule_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('payment_schedules');
+    }
+};

+ 22 - 0
database/migrations/2026_06_18_132630_make_schedule_id_nullable_on_payments_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('payments', function (Blueprint $table) {
+            $table->foreignId('schedule_id')->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('payments', function (Blueprint $table) {
+            $table->foreignId('schedule_id')->nullable(false)->change();
+        });
+    }
+};

+ 1 - 0
routes/authRoutes/payment.php

@@ -8,6 +8,7 @@ Route::post('/payment', [PaymentController::class, 'store'])->middleware('permis
 
 Route::get('/payment/platform-fees', [PaymentController::class, 'platformFees'])->middleware('permission:config.schedule,view');
 
+Route::post('/payment/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');
 

+ 1 - 0
routes/authRoutes/schedule.php

@@ -9,6 +9,7 @@ Route::get('/schedules/finished',              [ScheduleController::class, 'fini
 Route::get('/schedule/client-provider-blocks', [ScheduleController::class, 'clientProviderBlocks'])->middleware('permission:config.schedule,add');
 Route::get('/schedule/{id}',                   [ScheduleController::class, 'show'])->middleware('permission:config.schedule,view');
 Route::post('/schedule',                       [ScheduleController::class, 'store'])->middleware('permission:config.schedule,add');
+Route::post('/schedule/cart',                  [ScheduleController::class, 'storeCart'])->middleware('permission:config.schedule,add');
 Route::put('/schedule/{id}',                   [ScheduleController::class, 'update'])->middleware('permission:config.schedule,edit');
 Route::patch('/schedule/{id}/status',          [ScheduleController::class, 'updateStatus'])->middleware('permission:config.schedule,edit');
 Route::patch('/schedule/{id}/cancel',          [ScheduleController::class, 'cancelWithReason'])->middleware('permission:config.schedule,edit');