Gustavo Mantovani 2 недель назад
Родитель
Сommit
ff1a3fa48b

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

@@ -118,6 +118,32 @@ class PaymentController extends Controller
     }
 
     //
+    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,
+        );
+    }
 
     public function getSchedulePix(Schedule $schedule): JsonResponse
     {

+ 39 - 30
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;
@@ -94,21 +95,13 @@ class ScheduleController extends Controller
         );
     }
 
-    //
-
-    public function clientProviderBlocks(Request $request): JsonResponse
+    public function finished(): JsonResponse
     {
-        $validated = $request->validate([
-            'client_id'   => 'required|integer|exists:clients,id',
-            'provider_id' => 'required|integer|exists:providers,id',
-        ]);
+        $schedules = $this->scheduleService->getFinished();
 
-        $blocks = $this->scheduleService->getClientProviderBlocks(
-            (int) $validated['client_id'],
-            (int) $validated['provider_id'],
+        return $this->successResponse(
+            ScheduleResource::collection($schedules),
         );
-
-        return $this->successResponse(payload: $blocks);
     }
 
     public function groupedByClient(): JsonResponse
@@ -118,25 +111,14 @@ class ScheduleController extends Controller
         return $this->successResponse($grouped);
     }
 
-    public function finished(): JsonResponse
-    {
-        $schedules = $this->scheduleService->getFinished();
-
-        return $this->successResponse(
-            ScheduleResource::collection($schedules),
-        );
-    }
-
-    //
-
-    public function cancelWithReason(string $id, Request $request): JsonResponse
+    public function updateStatus(string $id, ScheduleRequest $request): JsonResponse
     {
         try {
             $validated = $request->validate([
-                'cancel_text' => 'required|string|min:5|max:1000',
+                'status' => 'required|in:pending,accepted,rejected,paid,cancelled,started,finished',
             ]);
 
-            $schedule = $this->scheduleService->cancelWithReason((int) $id, $validated['cancel_text']);
+            $schedule = $this->scheduleService->updateStatus($id, $validated['status']);
 
             return $this->successResponse(
                 payload: new ScheduleResource($schedule),
@@ -147,14 +129,14 @@ class ScheduleController extends Controller
         }
     }
 
-    public function updateStatus(string $id, ScheduleRequest $request): JsonResponse
+    public function cancelWithReason(string $id, Request $request): JsonResponse
     {
         try {
             $validated = $request->validate([
-                'status' => 'required|in:pending,accepted,rejected,paid,cancelled,started,finished',
+                'cancel_text' => 'required|string|min:5|max:1000',
             ]);
 
-            $schedule = $this->scheduleService->updateStatus($id, $validated['status']);
+            $schedule = $this->scheduleService->cancelWithReason((int) $id, $validated['cancel_text']);
 
             return $this->successResponse(
                 payload: new ScheduleResource($schedule),
@@ -165,6 +147,33 @@ class ScheduleController extends Controller
         }
     }
 
-    //
+    public function clientProviderBlocks(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'client_id'   => 'required|integer|exists:clients,id',
+            'provider_id' => 'required|integer|exists:providers,id',
+        ]);
+
+        $blocks = $this->scheduleService->getClientProviderBlocks(
+            (int) $validated['client_id'],
+            (int) $validated['provider_id'],
+        );
 
+        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);
+        }
+    }
 }

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

@@ -20,6 +20,8 @@ class PaymentResource extends JsonResource
             'id'                          => $this->id,
             'schedule_id'                 => $this->schedule_id,
             'schedule'                    => new ScheduleResource($this->whenLoaded('schedule')),
+            '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,
@@ -71,6 +73,8 @@ class PaymentResource extends JsonResource
         $charge      = $this->gateway_payload['charges'][0] ?? [];
         $transaction = $charge['last_transaction']          ?? [];
 
+        $transaction = $charge['last_transaction'] ?? [];
+
         $expiresAt = $charge['expires_at']
             ?? $transaction['expires_at']
             ?? $this->expires_at?->toISOString();

+ 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

@@ -26,6 +26,7 @@ use App\Models\Schedule;
 use App\Services\Pagarme\Concerns\FormatsPagarmeData;
 use App\Services\Pagarme\Concerns\MocksPagarmeRequests;
 use App\Services\Pagarme\Concerns\SendsPagarmeRequests;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 
@@ -155,6 +156,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,
@@ -204,12 +284,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(),
@@ -474,16 +556,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 buildPhonePayload(?string $phone): ?array

+ 45 - 3
app/Services/PaymentService.php

@@ -15,6 +15,7 @@ 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
@@ -119,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(),
@@ -129,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.');
                 }
@@ -326,7 +331,7 @@ class PaymentService
         }
 
         return $this->payAcceptedSchedule(
-            schedule: $schedule,
+            schedule:      $schedule,
             paymentMethod: 'pix',
         );
     }
@@ -473,4 +478,41 @@ class PaymentService
             $cart->update(['status' => CartStatusEnum::PAID->value]);
         }
     }
+
+    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 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']),
+        ];
+    }
+}

+ 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();
+        });
+    }
+};

+ 3 - 3
routes/authRoutes/payment.php

@@ -8,11 +8,11 @@ Route::post('/payment', [PaymentController::class, 'store'])->middleware('permis
 
 Route::get('/payment/platform-fees', [PaymentController::class, 'platformFees']);
 
-Route::get('/payment/schedule/{schedule}/pix',  [PaymentController::class, 'getSchedulePix']);
-Route::post('/payment/schedule/{schedule}/pay', [PaymentController::class, 'paySchedule']);
-
 // Route::post('/payment/cart/pay', [PaymentController::class, 'payCart']);
 
+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');
+
 Route::get('/payment/{id}',    [PaymentController::class, 'show'])->middleware('permission:payment,view');
 Route::put('/payment/{id}',    [PaymentController::class, 'update'])->middleware('permission:payment,edit');
 Route::delete('/payment/{id}', [PaymentController::class, 'destroy'])->middleware('permission:payment,delete');

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