Browse Source

Merge branch 'feature/diaria-gus-4leva' of Softpar/sfp_api_laravel_diarista into development

zntt 3 days ago
parent
commit
b0a77ebf63

+ 75 - 0
app/Commands/ConfirmarPagamento.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Commands;
+
+use App\Enums\PaymentStatusEnum;
+use App\Models\Payment;
+use App\Services\Pagarme\PagarmePaymentService;
+use App\Services\PaymentService;
+use Illuminate\Console\Command;
+
+class ConfirmarPagamento extends Command
+{
+    protected $signature = 'confirma_pagamento {payment_id? : ID do payment a confirmar (padrao: ultimo pendente)}';
+
+    protected $description = 'Simula a confirmacao de pagamento (Pagar.me) do ultimo agendamento solicitado, para facilitar testes locais';
+
+    public function __construct(
+        protected PagarmePaymentService $pagarmePaymentService,
+        protected PaymentService $paymentService,
+    ) {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        if (app()->environment('production')) {
+            $this->error('Este comando nao pode ser executado em producao.');
+
+            return Command::FAILURE;
+        }
+
+        $paymentId = $this->argument('payment_id');
+
+        $payment = $paymentId
+            ? Payment::query()->find($paymentId)
+            : Payment::query()
+                ->whereIn('status', [PaymentStatusEnum::PENDING, PaymentStatusEnum::PROCESSING, PaymentStatusEnum::AUTHORIZED])
+                ->latest('id')
+                ->first();
+
+        if (! $payment) {
+            $this->error($paymentId ? "Payment #{$paymentId} nao encontrado." : 'Nenhum payment pendente encontrado.');
+
+            return Command::FAILURE;
+        }
+
+        if ($payment->status === PaymentStatusEnum::PAID) {
+            $this->warn("Payment #{$payment->id} ja esta pago.");
+
+            return Command::SUCCESS;
+        }
+
+        $this->pagarmePaymentService->applyGatewayResponseToPayment($payment, [
+            'id'      => 'or_local_test',
+            'charges' => [[
+                'id'               => 'ch_local_test',
+                'status'           => 'paid',
+                'paid_at'          => now()->toISOString(),
+                'last_transaction' => [
+                    'id'     => 'tran_local_test',
+                    'status' => 'captured',
+                    'cost'   => 0,
+                ],
+            ]],
+        ]);
+
+        $this->paymentService->syncPaymentTargets($payment->fresh());
+
+        $status = $payment->fresh()->status->value;
+
+        $this->info("Payment #{$payment->id} (schedule #{$payment->schedule_id}) atualizado para status: {$status}");
+
+        return Command::SUCCESS;
+    }
+}

+ 3 - 2
app/Http/Controllers/CustomScheduleController.php

@@ -8,6 +8,7 @@ use App\Http\Requests\CustomScheduleRefuseOpportunityRequest;
 use App\Http\Requests\CustomScheduleRequest;
 use App\Http\Requests\CustomScheduleVerifyCodeRequest;
 use App\Http\Resources\CustomScheduleResource;
+use App\Http\Resources\ServicePackageResource;
 use App\Services\CustomScheduleService;
 use Illuminate\Http\JsonResponse;
 
@@ -173,9 +174,9 @@ class CustomScheduleController extends Controller
     public function acceptProposal($proposalId)
     {
         try {
-            $schedule = $this->customScheduleService->acceptProposal($proposalId);
+            $servicePackage = $this->customScheduleService->acceptProposal($proposalId);
 
-            return $this->successResponse($schedule, __('messages.provider_accepted'));
+            return $this->successResponse(new ServicePackageResource($servicePackage), __('messages.provider_accepted'));
         } catch (\Exception $e) {
             return $this->errorResponse($e->getMessage(), 400);
         }

+ 15 - 0
app/Http/Controllers/DashboardController.php

@@ -30,6 +30,21 @@ class DashboardController extends Controller
         }
     }
 
+    public function dadosPedidosCliente(): JsonResponse
+    {
+        try {
+            $dados = $this->service->dadosPedidosCliente();
+
+            return $this->successResponse(payload: $dados);
+        } catch (AuthorizationException $e) {
+            return $this->errorResponse(message: $e->getMessage(), code: 403);
+        } catch (ModelNotFoundException) {
+            return $this->errorResponse(message: __('messages.client_not_found'), code: 404);
+        } catch (\Exception $e) {
+            return $this->errorResponse(message: __('messages.error_fetching_data'), code: 500, exception: $e);
+        }
+    }
+
     public function dadosDashboardPrestador(): JsonResponse
     {
         try {

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

@@ -8,6 +8,7 @@ use App\Exceptions\PaymentMissingDataException;
 use App\Http\Requests\PayServicePackageRequest;
 use App\Http\Requests\PaymentRequest;
 use App\Http\Resources\PaymentResource;
+use App\Models\ScheduleProposal;
 use App\Models\ServicePackage;
 use App\Services\PaymentService;
 use Illuminate\Http\JsonResponse;
@@ -113,6 +114,61 @@ class PaymentController extends Controller
         return $this->successResponse(payload: new PaymentResource($item));
     }
 
+    //
+
+    public function payScheduleProposal(PayServicePackageRequest $request, ScheduleProposal $proposal): JsonResponse
+    {
+        $validated = $request->validated();
+
+        try {
+            $item = $this->service->payScheduleProposal(
+                proposalId:            $proposal->id,
+                paymentMethod:         data_get($validated, 'payment_method'),
+                clientPaymentMethodId: data_get($validated, 'client_payment_method_id'),
+
+                options: [
+                    'phone'   => data_get($validated, 'phone'),
+                    'card_id' => data_get($validated, 'card_id'),
+                ],
+            );
+        } catch (PaymentMissingDataException $e) {
+            return response()->json([
+                'message' => $e->getMessage(),
+                'error'   => 'missing_payment_data',
+            ], 422);
+        } catch (PaymentFailedException) {
+            return $this->errorResponse(message: __('messages.payment_not_confirmed'), code: 422);
+        } catch (PaymentException) {
+            return $this->errorResponse(message: __('messages.payment_error'), code: 422);
+        }
+
+        return $this->successResponse(
+            payload: new PaymentResource($item),
+            message: $item->status->message(),
+            code:    201,
+        );
+    }
+
+    //
+
+    public function getScheduleProposalPix(ScheduleProposal $proposal): JsonResponse
+    {
+        if ($proposal->schedule?->client?->user_id !== Auth::id()) {
+            abort(403);
+        }
+
+        try {
+            $item = $this->service->getOrCreateScheduleProposalPixPayment($proposal);
+        } catch (PaymentMissingDataException $e) {
+            return response()->json([
+                'message' => $e->getMessage(),
+                'error'   => 'missing_payment_data',
+            ], 422);
+        }
+
+        return $this->successResponse(payload: new PaymentResource($item));
+    }
+
     public function platformFees(): JsonResponse
     {
         return $this->successResponse(payload: $this->service->platformFees());

+ 1 - 0
app/Http/Requests/UpdateMeRequest.php

@@ -31,6 +31,7 @@ class UpdateMeRequest extends FormRequest
             'avatar'        => 'sometimes|file|image|mimes:jpg,jpeg,png,webp|max:5120',
             'avatar_base64' => 'sometimes|string|nullable',
             'push_notifications_enabled' => 'sometimes|boolean',
+            'first_access'  => 'sometimes|boolean',
         ];
     }
 }

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

@@ -19,6 +19,7 @@ class ClientResource extends JsonResource
             'document'         => $this->document,
             'user_id'          => $this->user_id,
             'selfie_verified'  => $this->selfie_verified,
+            'first_access'     => $this->first_access,
             'profile_media_id' => $this->profileMedia?->id,
             'profile_media'    => $this->profileMedia ? new MediaResource($this->profileMedia) : null,
             'user'             => new UserResource($this->whenLoaded('user')),

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

@@ -27,6 +27,7 @@ class DashboardClienteResource extends JsonResource
             'todaySchedules'             => data_get($this, 'todaySchedules'),
             'notifications'              => data_get($this, 'notifications'),
             'has_payment_methods'        => data_get($this, 'has_payment_methods'),
+            'has_location'               => data_get($this, 'has_location'),
             'pendingServicePackages'     => ServicePackageResource::collection(data_get($this, 'pendingServicePackages')),
         ];
     }

+ 3 - 0
app/Models/Client.php

@@ -23,6 +23,7 @@ use Illuminate\Support\Facades\Auth;
  * @property string|null $gateway_customer_code
  * @property int|null $profile_media_id
  * @property bool $selfie_verified
+ * @property bool $first_access
  * @property string|null $idempotency_key
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ProviderClientBlock> $blockedByProviders
  * @property-read int|null $blocked_by_providers_count
@@ -64,6 +65,7 @@ class Client extends Model
         'user_id',
         'profile_media_id',
         'selfie_verified',
+        'first_access',
     ];
 
     protected $casts = [
@@ -71,6 +73,7 @@ class Client extends Model
         'updated_at'      => 'datetime',
         'deleted_at'      => 'datetime',
         'selfie_verified' => 'boolean',
+        'first_access'    => 'boolean',
     ];
 
     public function user(): BelongsTo

+ 4 - 0
app/Services/ClientCalendarService.php

@@ -64,6 +64,10 @@ class ClientCalendarService
         $upcomingSchedules = Schedule::with('address:district,address,number,source_id,source,id')
             ->where('schedules.client_id', $client->id)
             ->whereIn('schedules.status', ['pending', 'accepted', 'paid', 'started'])
+            ->where(function ($query) {
+                $query->where('schedules.schedule_type', '!=', 'custom')
+                    ->orWhereIn('schedules.status', ['paid', 'started']);
+            })
             ->whereDate('schedules.date', '>=', now()->toDateString())
             ->leftJoin('providers', 'providers.id', '=', 'schedules.provider_id')
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')

+ 34 - 20
app/Services/CustomScheduleService.php

@@ -10,6 +10,7 @@ use App\Models\Provider;
 use App\Models\Schedule;
 use App\Models\ScheduleProposal;
 use App\Models\ScheduleRefuse;
+use App\Models\ServicePackage;
 use App\Rules\ScheduleBusinessRules;
 use App\Services\NotificationService;
 use Carbon\Carbon;
@@ -255,7 +256,7 @@ class CustomScheduleService
         $opportunities = Schedule::with([
             'client.user',
             'client.profileMedia',
-            'address',
+            'address:id,district,zip_code,latitude,longitude',
             'customSchedule.serviceType',
             'customSchedule.specialities',
         ])
@@ -459,24 +460,7 @@ class CustomScheduleService
 
             $provider = Provider::find($proposal->provider_id);
 
-            switch ($schedule->period_type) {
-                case '8':
-                    $baseAmount = $provider->daily_price_8h;
-                    break;
-                case '6':
-                    $baseAmount = $provider->daily_price_6h;
-                    break;
-                case '4':
-                    $baseAmount = $provider->daily_price_4h;
-                    break;
-                case '2':
-                    $baseAmount = $provider->daily_price_2h;
-                    break;
-                default:
-                    throw new \Exception(__('messages.invalid_schedule_period'));
-            }
-
-            $schedule->total_amount = $baseAmount;
+            $schedule->total_amount = $this->resolveProposalAmount($schedule, $provider);
 
             $schedule->save();
 
@@ -505,10 +489,40 @@ class CustomScheduleService
                 ->where('id', '!=', $proposalId)
                 ->delete();
 
-            return $schedule->fresh(['provider.user']);
+            $servicePackage = ServicePackage::create([
+                'client_id'   => $schedule->client_id,
+                'provider_id' => $schedule->provider_id,
+            ]);
+
+            $servicePackage->items()->create([
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return $servicePackage->fresh([
+                'items.schedule.client.user',
+                'items.schedule.provider.user',
+                'items.schedule.address',
+                'provider.user',
+            ]);
         });
     }
 
+    public function resolveProposalAmount(Schedule $schedule, Provider $provider): float
+    {
+        switch ($schedule->period_type) {
+            case '8':
+                return (float) $provider->daily_price_8h;
+            case '6':
+                return (float) $provider->daily_price_6h;
+            case '4':
+                return (float) $provider->daily_price_4h;
+            case '2':
+                return (float) $provider->daily_price_2h;
+            default:
+                throw new \Exception(__('messages.invalid_schedule_period'));
+        }
+    }
+
     public function refuseProposal($proposalId)
     {
         return DB::transaction(function () use ($proposalId) {

+ 312 - 128
app/Services/DashboardService.php

@@ -26,6 +26,8 @@ use Illuminate\Support\Facades\Storage;
 
 class DashboardService
 {
+    private const NEARBY_RADIUS_KM = 20.0;
+
     public function __construct(
         private readonly CustomScheduleService $customScheduleService,
         private readonly ZipCodeCoordinatesService $zipCodeCoordinatesService,
@@ -55,6 +57,8 @@ class DashboardService
             ->where('source_id', $cliente->id)
             ->with(['city', 'state'])
             ->select('id', 'source', 'source_id', 'address', 'number', 'district', 'nickname', 'address_type', 'city_id', 'state_id', 'is_primary')
+            ->orderByDesc('is_primary')
+            ->orderByDesc('id')
             ->first();
 
         $summaryInfos = [
@@ -194,17 +198,15 @@ class DashboardService
             ->orderByDesc('id')
             ->first();
 
-        $clientDistanceAddress = $this->addressForDistance($cliente->id, $clientPrimaryAddress);
+        $providersCloseCityId    = $clientPrimaryAddress?->city_id;
+        $providersCloseLatitude  = $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null;
+        $providersCloseLongitude = $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null;
 
-        $clientCoordinates = $this->zipCodeCoordinatesService->resolve(
-            $clientDistanceAddress?->latitude !== null ? (float) $clientDistanceAddress->latitude : null,
-            $clientDistanceAddress?->longitude !== null ? (float) $clientDistanceAddress->longitude : null,
-            $clientDistanceAddress?->zip_code,
-        );
+        $hasLocation = $providersCloseLatitude !== null && $providersCloseLongitude !== null;
 
         $providersCloseDistanceSelect = $this->distanceSelect(
-            data_get($clientCoordinates, 'latitude'),
-            data_get($clientCoordinates, 'longitude'),
+            $providersCloseLatitude,
+            $providersCloseLongitude,
         );
 
         $providerAddressLatestSubquery = DB::raw("
@@ -223,107 +225,121 @@ class DashboardService
             ) AS provider_address
         ");
 
-        $providersClose = Provider::leftJoin(
-            'users as provider_user',
-            'provider_user.id',
-            '=',
-            'providers.user_id'
-        )
-            ->visibleToCustomers()
-            ->leftJoin(
-                $providerAddressLatestSubquery,
-                'provider_address.source_id',
+        $providersClose = $hasLocation
+            ? Provider::leftJoin(
+                'users as provider_user',
+                'provider_user.id',
                 '=',
-                'providers.id'
-            )
-            ->whereNotNull('provider_address.id')
-            ->when(
-                $clientPrimaryAddress?->city_id,
-                fn($query, int $cityId) => $query->where('provider_address.city_id', $cityId)
+                'providers.user_id'
             )
-            ->whereNotIn('providers.id', $blockedProviderIds)
-            ->whereIn('providers.id', $providersWithWorkingDays)
-            ->whereNull('providers.deleted_at')
-            ->select(
-                'providers.id as provider_id',
-                'provider_user.name as provider_name',
-                'providers.gender',
-                'provider_address.id as address_id',
-                'provider_address.zip_code as provider_zip_code',
-                'provider_address.district',
-                'provider_address.latitude as provider_latitude',
-                'provider_address.longitude as provider_longitude',
-                'providers.average_rating',
-                'providers.total_services',
-                'providers.daily_price_8h',
-                'providers.daily_price_6h',
-                'providers.daily_price_4h',
-                'providers.daily_price_2h',
-
-                DB::raw("
-                (
-                    SELECT COUNT(*)
-                    FROM reviews
-                    LEFT JOIN schedules
-                        ON schedules.id = reviews.schedule_id
-                    WHERE reviews.origin = 'provider'
-                    AND schedules.provider_id = providers.id
-                ) AS total_reviews
-            "),
+                ->visibleToCustomers()
+                ->leftJoin(
+                    $providerAddressLatestSubquery,
+                    'provider_address.source_id',
+                    '=',
+                    'providers.id'
+                )
+                ->whereNotNull('provider_address.id')
+                ->where(function ($query) use ($providersCloseCityId, $providersCloseLatitude, $providersCloseLongitude) {
+                    if ($providersCloseCityId !== null) {
+                        $query->orWhere('provider_address.city_id', $providersCloseCityId);
+                    }
+
+                    if ($providersCloseLatitude !== null && $providersCloseLongitude !== null) {
+                        $query->orWhereRaw(
+                            DistanceService::withinRadiusSqlCondition(
+                                (float) $providersCloseLatitude,
+                                (float) $providersCloseLongitude,
+                                self::NEARBY_RADIUS_KM,
+                            )
+                        );
+                    }
+                })
+                ->whereNotIn('providers.id', $blockedProviderIds)
+                ->whereIn('providers.id', $providersWithWorkingDays)
+                ->whereNull('providers.deleted_at')
+                ->select(
+                    'providers.id as provider_id',
+                    'provider_user.name as provider_name',
+                    'providers.gender',
+                    'provider_address.id as address_id',
+                    'provider_address.zip_code as provider_zip_code',
+                    'provider_address.district',
+                    'provider_address.latitude as provider_latitude',
+                    'provider_address.longitude as provider_longitude',
+                    'providers.average_rating',
+                    'providers.total_services',
+                    'providers.daily_price_8h',
+                    'providers.daily_price_6h',
+                    'providers.daily_price_4h',
+                    'providers.daily_price_2h',
+
+                    DB::raw("
+                    (
+                        SELECT COUNT(*)
+                        FROM reviews
+                        LEFT JOIN schedules
+                            ON schedules.id = reviews.schedule_id
+                        WHERE reviews.origin = 'provider'
+                        AND schedules.provider_id = providers.id
+                    ) AS total_reviews
+                "),
 
-                $providersCloseDistanceSelect,
-            )
-            ->orderByRaw('distance_km ASC NULLS LAST')
-            ->get();
+                    $providersCloseDistanceSelect,
+                )
+                ->orderByRaw('distance_km ASC NULLS LAST')
+                ->get()
+            : collect();
 
-        $this->zipCodeCoordinatesService->preload(
-            $providersClose->whereNull('distance_km')->pluck('provider_zip_code')
-        );
+        if ($hasLocation) {
+            $this->zipCodeCoordinatesService->preload(
+                $providersClose->whereNull('distance_km')->pluck('provider_zip_code')
+            );
 
-        $providersClose->each(function ($item) use ($clientDistanceAddress) {
-            $item->gender_label = GenderEnum::labelFor($item->gender);
+            $providersClose->each(function ($item) use ($clientPrimaryAddress) {
+                $item->gender_label = GenderEnum::labelFor($item->gender);
 
-            if ($item->distance_km === null) {
-                $item->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
-                    $clientDistanceAddress?->latitude !== null ? (float) $clientDistanceAddress->latitude : null,
-                    $clientDistanceAddress?->longitude !== null ? (float) $clientDistanceAddress->longitude : null,
-                    $clientDistanceAddress?->zip_code,
-                    $item->provider_latitude !== null ? (float) $item->provider_latitude : null,
-                    $item->provider_longitude !== null ? (float) $item->provider_longitude : null,
-                    $item->provider_zip_code,
-                );
-            }
-            $item->specialities = ProviderSpeciality::query()
-                ->join('specialities', 'specialities.id', '=', 'provider_specialities.speciality_id')
-                ->where('provider_specialities.provider_id', $item->provider_id)
-                ->where('specialities.active', true)
-                ->orderBy('specialities.description')
-                ->get([
-                    'specialities.id',
-                    'specialities.description',
-                ]);
-
-            $item->age = Provider::query()
-                ->where('id', $item->provider_id)
-                ->value(DB::raw("DATE_PART('year', AGE(birth_date))"));
-
-            unset($item->provider_zip_code);
-
-            $item->daily_price_8h_base = $item->daily_price_8h;
-            $item->daily_price_6h_base = $item->daily_price_6h;
-            $item->daily_price_4h_base = $item->daily_price_4h;
-            $item->daily_price_2h_base = $item->daily_price_2h;
-
-            $item->daily_price_8h = $this->applyCreditCardFee($item->daily_price_8h);
-            $item->daily_price_6h = $this->applyCreditCardFee($item->daily_price_6h);
-            $item->daily_price_4h = $this->applyCreditCardFee($item->daily_price_4h);
-            $item->daily_price_2h = $this->applyCreditCardFee($item->daily_price_2h);
-        });
+                if ($item->distance_km === null) {
+                    $item->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
+                        $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null,
+                        $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null,
+                        $clientPrimaryAddress?->zip_code,
+                        $item->provider_latitude !== null ? (float) $item->provider_latitude : null,
+                        $item->provider_longitude !== null ? (float) $item->provider_longitude : null,
+                        $item->provider_zip_code,
+                    );
+                }
+                $item->specialities = ProviderSpeciality::query()
+                    ->join('specialities', 'specialities.id', '=', 'provider_specialities.speciality_id')
+                    ->where('provider_specialities.provider_id', $item->provider_id)
+                    ->where('specialities.active', true)
+                    ->orderBy('specialities.description')
+                    ->get([
+                        'specialities.id',
+                        'specialities.description',
+                    ]);
+
+                $item->age = Provider::query()
+                    ->where('id', $item->provider_id)
+                    ->value(DB::raw("DATE_PART('year', AGE(birth_date))"));
+
+                unset($item->provider_zip_code);
+
+                $item->daily_price_8h_base = $item->daily_price_8h;
+                $item->daily_price_6h_base = $item->daily_price_6h;
+                $item->daily_price_4h_base = $item->daily_price_4h;
+                $item->daily_price_2h_base = $item->daily_price_2h;
+
+                $item->daily_price_8h = $this->applyCreditCardFee($item->daily_price_8h);
+                $item->daily_price_6h = $this->applyCreditCardFee($item->daily_price_6h);
+                $item->daily_price_4h = $this->applyCreditCardFee($item->daily_price_4h);
+                $item->daily_price_2h = $this->applyCreditCardFee($item->daily_price_2h);
+            });
 
-        $providersClose = $providersClose
-            ->sortBy(fn($provider) => $provider->distance_km ?? PHP_FLOAT_MAX)
-            ->sortBy(fn($provider) => $provider->distance_km ?? PHP_FLOAT_MAX)
-            ->values();
+            $providersClose = $providersClose
+                ->sortBy(fn($provider) => $provider->distance_km ?? PHP_FLOAT_MAX)
+                ->values();
+        }
 
         $pendingSchedules = Schedule::with([
             'address:district,address,number,source_id,source,id,address_type',
@@ -399,8 +415,8 @@ class DashboardService
         });
 
         $proposalsDistanceSelect = DistanceService::sqlExpression(
-            data_get($clientCoordinates, 'latitude'),
-            data_get($clientCoordinates, 'longitude'),
+            $providersCloseLatitude,
+            $providersCloseLongitude,
         );
 
         $schedulesProposals = ScheduleProposal::query()
@@ -433,6 +449,12 @@ class DashboardService
                 '=',
                 'providers.id'
             )
+            ->leftJoin(
+                'addresses as schedule_address',
+                'schedule_address.id',
+                '=',
+                'schedules.address_id'
+            )
             ->where('schedules.client_id', $cliente->id)
             ->where('schedules.schedule_type', 'custom')
             ->where('schedules.status', 'pending')
@@ -464,6 +486,11 @@ class DashboardService
                 'provider_address.longitude as provider_longitude',
                 'provider_address.zip_code as provider_zip_code',
 
+                'schedule_address.address as address',
+                'schedule_address.number as address_number',
+                'schedule_address.district as address_district',
+                'schedule_address.address_type as address_type',
+
                 $proposalsDistanceSelect,
             ])
             ->get();
@@ -472,21 +499,31 @@ class DashboardService
             $schedulesProposals->whereNull('distance_km')->pluck('provider_zip_code')
         );
 
-        $custom_schedules_with_no_proposals = Schedule::where('client_id', $cliente->id)
+        $custom_schedules_with_no_proposals = Schedule::with('address:district,address,number,source_id,source,id')
+            ->where('client_id', $cliente->id)
             ->where('schedule_type', 'custom')
             ->where('status', 'pending')
             ->whereDate('date', '>=', now()->toDateString())
             ->doesntHave('proposals')
             ->get();
 
-        $schedulesProposals->each(function ($item) use ($clientDistanceAddress) {
+        $schedulesProposals->each(function ($item) use ($clientPrimaryAddress) {
             $item->gender_label = GenderEnum::labelFor($item->gender);
 
+            $item->address = [
+                'address'      => $item->address,
+                'number'       => $item->address_number,
+                'district'     => $item->address_district,
+                'address_type' => $item->address_type,
+            ];
+
+            unset($item->address_number, $item->address_district);
+
             if ($item->distance_km === null) {
                 $item->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
-                    $clientDistanceAddress?->latitude !== null ? (float) $clientDistanceAddress->latitude : null,
-                    $clientDistanceAddress?->longitude !== null ? (float) $clientDistanceAddress->longitude : null,
-                    $clientDistanceAddress?->zip_code,
+                    $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null,
+                    $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null,
+                    $clientPrimaryAddress?->zip_code,
                     $item->provider_latitude !== null ? (float) $item->provider_latitude : null,
                     $item->provider_longitude !== null ? (float) $item->provider_longitude : null,
                     $item->provider_zip_code,
@@ -632,10 +669,162 @@ class DashboardService
             'customSchedulesNoProposals' => $custom_schedules_with_no_proposals,
             'notifications'              => $notifications,
             'has_payment_methods'        => $hasPaymentMethods,
+            'has_location'               => $hasLocation,
             'pendingServicePackages'     => $pendingServicePackages,
         ];
     }
 
+    public function dadosPedidosCliente(): array
+    {
+        $user = Auth::user();
+
+        if ($user->type !== UserTypeEnum::CLIENT) {
+            throw new AuthorizationException(__('messages.only_clients_allowed'));
+        }
+
+        $cliente = Client::where('user_id', $user->id)->firstOrFail();
+
+        $clientPrimaryAddress = Address::where('source', 'client')
+            ->where('source_id', $cliente->id)
+            ->orderByDesc('is_primary')
+            ->orderByDesc('id')
+            ->first();
+
+        $proposalsDistanceSelect = DistanceService::sqlExpression(
+            $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null,
+            $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null,
+        );
+
+        $schedulesProposals = ScheduleProposal::query()
+            ->leftJoin(
+                'schedules',
+                'schedule_proposals.schedule_id',
+                '=',
+                'schedules.id'
+            )
+            ->leftJoin(
+                'providers',
+                'schedule_proposals.provider_id',
+                '=',
+                'providers.id'
+            )
+            ->whereExists(Provider::hasActivePrimaryBankAccount())
+            ->leftJoin('users', 'providers.user_id', '=', 'users.id')
+            ->leftJoin(
+                DB::raw("
+                    (
+                        SELECT DISTINCT ON (source_id)
+                            *
+                        FROM addresses
+                        WHERE source = 'provider'
+                        AND deleted_at IS NULL
+                        ORDER BY source_id, is_primary DESC
+                    ) AS provider_address
+                "),
+                'provider_address.source_id',
+                '=',
+                'providers.id'
+            )
+            ->leftJoin(
+                'addresses as schedule_address',
+                'schedule_address.id',
+                '=',
+                'schedules.address_id'
+            )
+            ->where('schedules.client_id', $cliente->id)
+            ->where('schedules.schedule_type', 'custom')
+            ->where('schedules.status', 'pending')
+            ->whereNull('schedules.deleted_at')
+            ->whereDate('schedules.date', '>=', now()->toDateString())
+            ->orderBy('schedule_proposals.created_at', 'desc')
+            ->select([
+                'schedule_proposals.id',
+
+                DB::raw("
+                    DATE_PART('year', AGE(providers.birth_date)) AS idade
+                "),
+
+                'providers.id as provider_id',
+                'providers.gender',
+                'schedules.id as schedule_id',
+                'schedules.date',
+                'schedules.start_time',
+                'schedules.end_time',
+                'schedules.period_type',
+                'schedules.total_amount',
+                'providers.daily_price_8h',
+                'providers.average_rating',
+                'providers.total_services',
+
+                'users.name as provider_name',
+
+                'provider_address.latitude as provider_latitude',
+                'provider_address.longitude as provider_longitude',
+                'provider_address.zip_code as provider_zip_code',
+
+                'schedule_address.address as address',
+                'schedule_address.number as address_number',
+                'schedule_address.district as address_district',
+                'schedule_address.address_type as address_type',
+
+                $proposalsDistanceSelect,
+            ])
+            ->get();
+
+        $this->zipCodeCoordinatesService->preload(
+            $schedulesProposals->whereNull('distance_km')->pluck('provider_zip_code')
+        );
+
+        $customSchedulesNoProposals = Schedule::with('address:district,address,number,source_id,source,id')
+            ->where('client_id', $cliente->id)
+            ->where('schedule_type', 'custom')
+            ->where('status', 'pending')
+            ->whereDate('date', '>=', now()->toDateString())
+            ->doesntHave('proposals')
+            ->get();
+
+        $schedulesProposals->each(function ($item) use ($clientPrimaryAddress) {
+            $item->gender_label = GenderEnum::labelFor($item->gender);
+
+            $item->address = [
+                'address'      => $item->address,
+                'number'       => $item->address_number,
+                'district'     => $item->address_district,
+                'address_type' => $item->address_type,
+            ];
+
+            unset($item->address_number, $item->address_district);
+
+            if ($item->distance_km === null) {
+                $item->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
+                    $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null,
+                    $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null,
+                    $clientPrimaryAddress?->zip_code,
+                    $item->provider_latitude !== null ? (float) $item->provider_latitude : null,
+                    $item->provider_longitude !== null ? (float) $item->provider_longitude : null,
+                    $item->provider_zip_code,
+                );
+            }
+
+            unset(
+                $item->provider_latitude,
+                $item->provider_longitude,
+                $item->provider_zip_code,
+            );
+        });
+
+        $providerPhotoUrls = $this->providerPhotoUrls($schedulesProposals->pluck('provider_id'));
+
+        $schedulesProposals->each(function ($item) use ($providerPhotoUrls) {
+            $item->provider_photo = $providerPhotoUrls->get($item->provider_id);
+        });
+
+        return [
+            'schedulesProposals'         => $schedulesProposals,
+            'customSchedulesNoProposals' => $customSchedulesNoProposals,
+        ];
+    }
+
     public function dadosDashboardPrestador(): array
     {
         $user = Auth::user();
@@ -826,9 +1015,11 @@ class DashboardService
             ->get();
 
 
-        $pendingConfirmation = Schedule::with(
-            'address:district,address,number,source_id,source,id,zip_code,latitude,longitude'
-        )
+        $pendingConfirmation = Schedule::with([
+            'address' => fn ($query) => $query->withTrashed()->select([
+                'district', 'address', 'number', 'source_id', 'source', 'id', 'zip_code', 'latitude', 'longitude',
+            ]),
+        ])
             ->where('schedules.provider_id', $provider->id)
             ->where('schedules.status', 'accepted')
             ->whereDate('schedules.date', '>=', now()->toDateString())
@@ -872,7 +1063,11 @@ class DashboardService
             );
         });
 
-        $nextSchedules = Schedule::with('address:district,address,number,source_id,source,id,zip_code,latitude,longitude')
+        $nextSchedules = Schedule::with([
+            'address' => fn ($query) => $query->withTrashed()->select([
+                'district', 'address', 'number', 'source_id', 'source', 'id', 'zip_code', 'latitude', 'longitude',
+            ]),
+        ])
             ->where('schedules.provider_id', $provider->id)
             ->where('schedules.status', 'paid')
             ->whereDate('schedules.date', '>=', now()->toDateString())
@@ -992,6 +1187,8 @@ class DashboardService
                 'providers.birth_date as provider_birth_date',
                 'providers.gender',
                 'custom_schedules.offers_meal',
+                'custom_schedules.min_price',
+                'custom_schedules.max_price',
             )
             ->firstOrFail();
 
@@ -1006,6 +1203,8 @@ class DashboardService
             'gender_label'        => GenderEnum::labelFor($schedule->gender),
             'offers_meal'         => $schedule->offers_meal,
             'specialities'        => $schedule->specialities,
+            'min_price'           => $schedule->min_price,
+            'max_price'           => $schedule->max_price,
 
             'provider_photo' => $providerPhoto,
         ];
@@ -1075,21 +1274,6 @@ class DashboardService
 
     //
 
-    private function addressForDistance(int $clientId, ?Address $primaryAddress): ?Address
-    {
-        if ($primaryAddress?->latitude !== null && $primaryAddress?->longitude !== null) {
-            return $primaryAddress;
-        }
-
-        return Address::where('source', 'client')
-            ->where('source_id', $clientId)
-            ->whereNotNull('latitude')
-            ->whereNotNull('longitude')
-            ->orderByDesc('is_primary')
-            ->orderByDesc('id')
-            ->first() ?? $primaryAddress;
-    }
-
     private function distanceSelect(?float $clientLatitude, ?float $clientLongitude): \Illuminate\Contracts\Database\Query\Expression
     {
         return DistanceService::sqlExpression($clientLatitude, $clientLongitude);

+ 47 - 21
app/Services/DistanceService.php

@@ -39,33 +39,59 @@ class DistanceService
             return DB::raw("NULL as {$alias}");
         }
 
+        $formula = self::distanceFormula($clientLatitude, $clientLongitude, $targetLatCol, $targetLngCol);
+
         return DB::raw("
             CASE
                 WHEN {$targetLatCol} IS NOT NULL
                 AND {$targetLngCol} IS NOT NULL
-                THEN ROUND(
-                    (
-                        " . self::EARTH_RADIUS_KM . " * ACOS(
-                            LEAST(
-                                1,
-                                GREATEST(
-                                    -1,
-                                    COS(RADIANS({$clientLatitude}))
-                                        * COS(RADIANS({$targetLatCol}))
-                                        * COS(
-                                            RADIANS({$targetLngCol})
-                                            - RADIANS({$clientLongitude})
-                                        )
-                                        + SIN(RADIANS({$clientLatitude}))
-                                        * SIN(RADIANS({$targetLatCol}))
-                                )
-                            )
-                        )
-                    )::numeric,
-                    1
-                )
+                THEN ROUND(({$formula})::numeric, 1)
                 ELSE NULL
             END AS {$alias}
         ");
     }
+
+    public static function withinRadiusSqlCondition(
+        float $clientLatitude,
+        float $clientLongitude,
+        float $radiusKm,
+        string $targetLatCol = 'provider_address.latitude',
+        string $targetLngCol = 'provider_address.longitude',
+    ): string {
+        $formula = self::distanceFormula($clientLatitude, $clientLongitude, $targetLatCol, $targetLngCol);
+
+        return "
+            (
+                {$targetLatCol} IS NOT NULL
+                AND {$targetLngCol} IS NOT NULL
+                AND ROUND(({$formula})::numeric, 1) <= {$radiusKm}
+            )
+        ";
+    }
+
+    private static function distanceFormula(
+        float $clientLatitude,
+        float $clientLongitude,
+        string $targetLatCol,
+        string $targetLngCol,
+    ): string {
+        return "
+            " . self::EARTH_RADIUS_KM . " * ACOS(
+                LEAST(
+                    1,
+                    GREATEST(
+                        -1,
+                        COS(RADIANS({$clientLatitude}))
+                            * COS(RADIANS({$targetLatCol}))
+                            * COS(
+                                RADIANS({$targetLngCol})
+                                - RADIANS({$clientLongitude})
+                            )
+                            + SIN(RADIANS({$clientLatitude}))
+                            * SIN(RADIANS({$targetLatCol}))
+                    )
+                )
+            )
+        ";
+    }
 }

+ 4 - 3
app/Services/Pagarme/PagarmePaymentService.php

@@ -195,10 +195,11 @@ class PagarmePaymentService
             ? []
             : ['split' => $split];
 
-        $metadata = [
-            'service_package_id' => (string) $payment->service_package_id,
+        $metadata = array_filter([
+            'service_package_id' => $payment->service_package_id ? (string) $payment->service_package_id : null,
+            'schedule_id'        => $payment->schedule_id ? (string) $payment->schedule_id : null,
             'schedule_ids'       => $schedules->pluck('id')->implode(','),
-        ];
+        ], fn ($value) => $value !== null && $value !== '');
 
         if ($paymentMethod === 'credit_card') {
             $creditCard = new CreditCardData(

+ 359 - 2
app/Services/PaymentService.php

@@ -14,6 +14,7 @@ use App\Models\ClientPaymentMethod;
 use App\Models\Payment;
 use App\Models\PaymentSplit;
 use App\Models\Schedule;
+use App\Models\ScheduleProposal;
 use App\Services\Pagarme\PagarmePaymentService;
 use Carbon\Carbon;
 use Illuminate\Auth\Access\AuthorizationException;
@@ -411,6 +412,345 @@ class PaymentService
         return $payment->fresh(['client.user', 'provider.user', 'servicePackage.items.schedule']);
     }
 
+    public function payScheduleProposal(
+        int    $proposalId,
+        string $paymentMethod,
+        ?int   $clientPaymentMethodId = null,
+        array  $options               = [],
+    ): Payment {
+        $userId = (int) Auth::id();
+
+        if (! in_array($paymentMethod, ['credit_card', 'pix'], true)) {
+            throw new PaymentException;
+        }
+
+        $paymentData = DB::transaction(function () use (
+            $proposalId,
+            $userId,
+            $paymentMethod,
+            $clientPaymentMethodId,
+            $options,
+        ): array {
+            $proposal = ScheduleProposal::query()
+                ->lockForUpdate()
+                ->with(['provider', 'schedule.client'])
+                ->findOrFail($proposalId);
+
+            $schedule = $proposal->schedule;
+            $provider = $proposal->provider;
+
+            if ($schedule?->client?->user_id !== $userId) {
+                throw new AuthorizationException;
+            }
+
+            $schedule->setAttribute('total_amount', app(CustomScheduleService::class)->resolveProposalAmount($schedule, $provider));
+
+            $schedules = collect([$schedule]);
+
+            $existingPayment = Payment::query()
+                ->where('metadata->schedule_proposal_id', (string) $proposal->id)
+                ->whereIn('status', [
+                    PaymentStatusEnum::PENDING->value,
+                    PaymentStatusEnum::PROCESSING->value,
+                    PaymentStatusEnum::AUTHORIZED->value,
+                    PaymentStatusEnum::PAID->value,
+                ])
+                ->latest('id')
+                ->first();
+
+            if (! $existingPayment && $schedule->provider_id) {
+                throw new PaymentException;
+            }
+
+            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 PaymentException;
+                    }
+
+                    [, $cardId] = $this->resolveCard(
+                        clientId:              $schedule->client_id,
+                        paymentMethod:         $paymentMethod,
+                        clientPaymentMethodId: $clientPaymentMethodId ?? $existingPayment->client_payment_method_id,
+                        cardId:                data_get($options, 'card_id'),
+                    );
+
+                    $this->servicePackagePaymentTotals($schedules, $paymentMethod);
+
+                    return [
+                        'payment'   => $existingPayment,
+                        'schedules' => $schedules,
+                        'cardId'    => $cardId,
+                    ];
+                } else {
+                    if ($existingPayment->payment_method !== $paymentMethod && $existingPayment->status !== PaymentStatusEnum::PAID) {
+                        throw new PaymentException;
+                    }
+
+                    return ['existing' => $existingPayment];
+                }
+            }
+
+            [$clientPaymentMethod, $cardId] = $this->resolveCard(
+                clientId:              $schedule->client_id,
+                paymentMethod:         $paymentMethod,
+                clientPaymentMethodId: $clientPaymentMethodId,
+                cardId:                data_get($options, 'card_id'),
+            );
+
+            $totals = $this->servicePackagePaymentTotals($schedules, $paymentMethod);
+
+            $payment = Payment::create([
+                'schedule_id'              => $schedule->id,
+                'service_package_id'       => null,
+                'client_id'                => $schedule->client_id,
+                'provider_id'              => $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'             => data_get($totals, 'gross_amount'),
+                'gateway_fee_amount'       => 0,
+                'platform_fee_amount'      => data_get($totals, 'platform_fee_amount'),
+                'net_amount'               => data_get($totals, 'gross_amount'),
+                'currency'                 => 'BRL',
+                'installments'             => 1,
+                'expires_at'               => $paymentMethod === 'pix' ? Carbon::now()->addMinutes(30) : null,
+
+                'metadata' => [
+                    'schedule_proposal_id' => (string) $proposal->id,
+                    'schedule_id'          => (string) $schedule->id,
+                    'service_amount'       => number_format(data_get($totals, 'service_amount'), 2, '.', ''),
+                    'platform_fee'         => number_format(data_get($totals, 'platform_fee_amount'), 2, '.', ''),
+                ],
+            ]);
+
+            PaymentSplit::create([
+                'payment_id'                        => $payment->id,
+                'provider_id'                       => $provider->id,
+                'gateway_provider'                  => 'pagarme',
+                'gateway_transfer_target_reference' => $provider->recipient_id,
+                'gateway_transfer_target_label'     => 'recipient',
+                'status'                            => PaymentSplitStatusEnum::PENDING,
+                'gross_amount'                      => data_get($totals, 'service_amount'),
+                'gateway_fee_amount'                => 0,
+                'net_amount'                        => data_get($totals, 'service_amount'),
+
+                'metadata' => [
+                    'schedule_proposal_id' => (string) $proposal->id,
+                    'schedule_id'          => (string) $schedule->id,
+                ],
+            ]);
+
+            return compact('payment', 'schedules', 'cardId');
+        });
+
+        if (data_get($paymentData, 'existing')) {
+            $existingPayment = data_get($paymentData, 'existing');
+
+            $this->syncPaymentTargets($existingPayment);
+
+            return $existingPayment->fresh(['client.user', 'provider.user', 'schedule', 'servicePackage.items.schedule']);
+        }
+
+        /** @var Payment $payment */
+        $payment = data_get($paymentData, 'payment');
+
+        /** @var SupportCollection $schedules */
+        $schedules = data_get($paymentData, 'schedules');
+
+        try {
+            $schedules->first()->ensureCustomerPhone(data_get($options, 'phone'));
+
+            $orderResponse = $this->pagarmePaymentService->processServicePackagePayment(
+                payment:       $payment,
+                schedules:     $schedules,
+                paymentMethod: $paymentMethod,
+                cardId:        data_get($paymentData, 'cardId'),
+                options:       $options,
+            );
+        } catch (\Throwable $e) {
+            $this->failPayment($payment, $e->getMessage());
+
+            throw $e;
+        }
+
+        $payment = $this->pagarmePaymentService->applyGatewayResponseToPayment($payment, $orderResponse);
+
+        $this->syncPaymentTargets($payment);
+
+        if ($payment->status === PaymentStatusEnum::FAILED) {
+            throw new PaymentFailedException;
+        }
+
+        return $payment->fresh(['client.user', 'provider.user', 'schedule', 'servicePackage.items.schedule']);
+    }
+
+    public function getOrCreateScheduleProposalPixPayment(ScheduleProposal $proposal): Payment
+    {
+        $userId = (int) Auth::id();
+
+        $proposal->loadMissing('schedule.client');
+
+        $schedule = $proposal->schedule;
+
+        if ($schedule?->client?->user_id !== $userId) {
+            throw new AuthorizationException;
+        }
+
+        $existingPayment = Payment::query()
+            ->where('metadata->schedule_proposal_id', (string) $proposal->id)
+            ->where('payment_method', 'pix')
+            ->whereIn('status', [
+                PaymentStatusEnum::PENDING->value,
+                PaymentStatusEnum::PROCESSING->value,
+                PaymentStatusEnum::AUTHORIZED->value,
+                PaymentStatusEnum::PAID->value,
+            ])
+            ->latest('id')
+            ->first();
+
+        if ($existingPayment && $this->isExpiredPixPayment($existingPayment)) {
+            $existingPayment->forceFill([
+                'status'          => PaymentStatusEnum::FAILED,
+                'failed_at'       => Carbon::now(),
+                'failure_message' => 'Pagamento Pix expirado.',
+            ])->save();
+
+            PaymentSplit::query()
+                ->where('payment_id', $existingPayment->id)
+                ->update(['status' => PaymentSplitStatusEnum::FAILED]);
+
+            $existingPayment = null;
+        }
+
+        if ($existingPayment) {
+            if ($this->isIncompleteGatewayPayment($existingPayment)) {
+                $existingPayment->forceFill([
+                    'status'          => PaymentStatusEnum::FAILED,
+                    'failed_at'       => Carbon::now(),
+                    'failure_message' => 'Pagamento pendente sem retorno do gateway.',
+                ])->save();
+
+                PaymentSplit::query()
+                    ->where('payment_id', $existingPayment->id)
+                    ->update(['status' => PaymentSplitStatusEnum::FAILED]);
+            } else {
+                $this->syncPaymentTargets($existingPayment);
+
+                return $existingPayment;
+            }
+        }
+
+        $paymentData = DB::transaction(function () use ($proposal, $userId): array {
+            $proposal = ScheduleProposal::query()
+                ->lockForUpdate()
+                ->with(['provider', 'schedule.client'])
+                ->findOrFail($proposal->id);
+
+            $schedule = $proposal->schedule;
+            $provider = $proposal->provider;
+
+            if ($schedule?->client?->user_id !== $userId) {
+                throw new AuthorizationException;
+            }
+
+            if ($schedule->provider_id) {
+                throw new PaymentException;
+            }
+
+            $schedule->setAttribute('total_amount', app(CustomScheduleService::class)->resolveProposalAmount($schedule, $provider));
+
+            $schedules = collect([$schedule]);
+
+            $totals = $this->servicePackagePaymentTotals($schedules, 'pix');
+
+            $payment = Payment::create([
+                'schedule_id'              => $schedule->id,
+                'service_package_id'       => null,
+                'client_id'                => $schedule->client_id,
+                'provider_id'              => $provider->id,
+                'client_payment_method_id' => null,
+                'gateway_provider'         => 'pagarme',
+                'gateway_code'             => 'payment-'.(string) Str::uuid(),
+                'payment_method'           => 'pix',
+                'status'                   => PaymentStatusEnum::PENDING,
+                'gross_amount'             => data_get($totals, 'gross_amount'),
+                'gateway_fee_amount'       => 0,
+                'platform_fee_amount'      => data_get($totals, 'platform_fee_amount'),
+                'net_amount'               => data_get($totals, 'gross_amount'),
+                'currency'                 => 'BRL',
+                'installments'             => 1,
+                'expires_at'               => Carbon::now()->addMinutes(30),
+
+                'metadata' => [
+                    'schedule_proposal_id' => (string) $proposal->id,
+                    'schedule_id'          => (string) $schedule->id,
+                    'service_amount'       => number_format(data_get($totals, 'service_amount'), 2, '.', ''),
+                    'platform_fee'         => number_format(data_get($totals, 'platform_fee_amount'), 2, '.', ''),
+                ],
+            ]);
+
+            PaymentSplit::create([
+                'payment_id'                        => $payment->id,
+                'provider_id'                       => $provider->id,
+                'gateway_provider'                  => 'pagarme',
+                'gateway_transfer_target_reference' => $provider->recipient_id,
+                'gateway_transfer_target_label'     => 'recipient',
+                'status'                            => PaymentSplitStatusEnum::PENDING,
+                'gross_amount'                      => data_get($totals, 'service_amount'),
+                'gateway_fee_amount'                => 0,
+                'net_amount'                        => data_get($totals, 'service_amount'),
+
+                'metadata' => [
+                    'schedule_proposal_id' => (string) $proposal->id,
+                    'schedule_id'          => (string) $schedule->id,
+                ],
+            ]);
+
+            return compact('payment', 'schedules');
+        });
+
+        /** @var Payment $payment */
+        $payment = data_get($paymentData, 'payment');
+
+        /** @var SupportCollection $schedules */
+        $schedules = data_get($paymentData, 'schedules');
+
+        try {
+            $schedules->first()->ensureCustomerPhone();
+
+            $orderResponse = $this->pagarmePaymentService->processServicePackagePayment(
+                payment:       $payment,
+                schedules:     $schedules,
+                paymentMethod: 'pix',
+                cardId:        null,
+                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', 'schedule', 'servicePackage.items.schedule']);
+    }
+
     //
 
     private function isExpiredPixPayment(Payment $payment): bool
@@ -495,9 +835,26 @@ class PaymentService
 
         $payment->loadMissing('schedule');
 
-        if ($payment->schedule) {
-            $this->syncScheduleStatusAfterPayment($payment->schedule, $payment);
+        if (! $payment->schedule) {
+            return;
+        }
+
+        $proposalId = data_get($payment->metadata, 'schedule_proposal_id');
+
+        if ($proposalId && ! $payment->schedule->provider_id) {
+            $servicePackage = app(CustomScheduleService::class)->acceptProposal((int) $proposalId);
+
+            $payment->update([
+                'schedule_id'        => null,
+                'service_package_id' => $servicePackage->id,
+            ]);
+
+            $this->syncPaymentTargets($payment->fresh());
+
+            return;
         }
+
+        $this->syncScheduleStatusAfterPayment($payment->schedule, $payment);
     }
 
     private function notifyProviderAndScheduleStart(Schedule $schedule): void

+ 4 - 4
app/Services/ScheduleService.php

@@ -149,7 +149,7 @@ class ScheduleService
 
             $schedule = Schedule::with(['provider.user', 'client.user', 'address'])->findOrFail($id);
 
-            if (! $fromPackage && in_array($status, ['accepted', 'rejected']) && Auth::user()->type === UserTypeEnum::PROVIDER) {
+            if (! $fromPackage && in_array($status, ['accepted', 'rejected']) && Auth::user()?->type === UserTypeEnum::PROVIDER) {
                 $belongsToServicePackage = DB::table('service_package_items')
                     ->where('schedule_id', $schedule->id)
                     ->exists();
@@ -191,7 +191,7 @@ class ScheduleService
                 case 'accepted':
                     $notificationService = app(NotificationService::class);
 
-                    switch (Auth::user()->type) {
+                    switch (Auth::user()?->type) {
                         case UserTypeEnum::PROVIDER:
                             $notificationService->create([
                                 'title'       => __('notifications.schedule_accepted_title'),
@@ -227,7 +227,7 @@ class ScheduleService
                 case 'cancelled':
                     $notificationService = app(NotificationService::class);
 
-                    switch (Auth::user()->type) {
+                    switch (Auth::user()?->type) {
                         case UserTypeEnum::CLIENT:
                             $notificationService->create([
                                 'title'       => __('notifications.schedule_cancelled_title'),
@@ -304,7 +304,7 @@ class ScheduleService
                 case 'paid':
                     $notificationService = app(NotificationService::class);
 
-                    switch (Auth::user()->type) {
+                    switch (Auth::user()?->type) {
                         case UserTypeEnum::CLIENT:
                             if ($schedule->provider_id) {
                                 $notificationService->create([

+ 46 - 39
app/Services/SearchService.php

@@ -8,16 +8,22 @@ use App\Models\Client;
 use App\Models\Provider;
 use App\Models\ProviderServicesType;
 use App\Rules\ScheduleBusinessRules;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 
 class SearchService
 {
+    private const NEARBY_RADIUS_KM = 20.0;
+
     public function __construct(
         private readonly ZipCodeCoordinatesService $zipCodeCoordinatesService,
     ) {}
 
+    /**
+     * @return array{providers: array, has_location: bool}
+     */
     public function buscaPrestadores(?string $name = null, ?string $date = null): array
     {
         $user = Auth::user();
@@ -34,18 +40,20 @@ class SearchService
             ->orderByDesc('id')
             ->first();
 
-        $clientDistanceAddress = $this->addressForDistance($cliente->id, $clientPrimaryAddress);
+        $cityId    = $clientPrimaryAddress?->city_id;
+        $clientLat = $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null;
+        $clientLng = $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null;
 
-        $clientCoordinates = $this->zipCodeCoordinatesService->resolve(
-            $clientDistanceAddress?->latitude !== null ? (float) $clientDistanceAddress->latitude : null,
-            $clientDistanceAddress?->longitude !== null ? (float) $clientDistanceAddress->longitude : null,
-            $clientDistanceAddress?->zip_code,
-        );
+        $hasLocation = $clientLat !== null && $clientLng !== null;
 
-        $distanceSelect = $this->distanceSelect(
-            data_get($clientCoordinates, 'latitude'),
-            data_get($clientCoordinates, 'longitude'),
-        );
+        if (! $hasLocation) {
+            return [
+                'providers'    => [],
+                'has_location' => false,
+            ];
+        }
+
+        $distanceSelect = $this->distanceSelect($clientLat, $clientLng);
 
         $baseQuery = Provider::leftJoin(
             'users as provider_user',
@@ -113,32 +121,38 @@ class SearchService
 
                 $distanceSelect,
             )
+            ->where(function ($query) use ($cityId, $clientLat, $clientLng) {
+                if ($cityId !== null) {
+                    $query->orWhere('provider_address.city_id', $cityId);
+                }
+
+                if ($clientLat !== null && $clientLng !== null) {
+                    $query->orWhereRaw(
+                        DistanceService::withinRadiusSqlCondition(
+                            (float) $clientLat,
+                            (float) $clientLng,
+                            self::NEARBY_RADIUS_KM,
+                        )
+                    );
+                }
+            })
             ->orderByRaw('distance_km ASC NULLS LAST');
 
-        $providers = (clone $baseQuery)
-            ->when(
-                $clientPrimaryAddress?->city_id,
-                fn ($query, int $cityId) => $query->where('provider_address.city_id', $cityId)
-            )
-            ->get();
-
-        if ($providers->isEmpty() && $clientPrimaryAddress?->city_id) {
-            $providers = $baseQuery->get();
-        }
+        $providers = $baseQuery->get();
 
         $this->zipCodeCoordinatesService->preload(
             $providers->whereNull('distance_km')->pluck('provider_zip_code')
         );
 
-        $providers->each(function ($provider) use ($clientDistanceAddress) {
+        $providers->each(function ($provider) use ($clientPrimaryAddress) {
             if ($provider->distance_km !== null) {
                 return;
             }
 
             $provider->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
-                $clientDistanceAddress?->latitude !== null ? (float) $clientDistanceAddress->latitude : null,
-                $clientDistanceAddress?->longitude !== null ? (float) $clientDistanceAddress->longitude : null,
-                $clientDistanceAddress?->zip_code,
+                $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null,
+                $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null,
+                $clientPrimaryAddress?->zip_code,
                 $provider->provider_latitude !== null ? (float) $provider->provider_latitude : null,
                 $provider->provider_longitude !== null ? (float) $provider->provider_longitude : null,
                 $provider->provider_zip_code,
@@ -162,6 +176,14 @@ class SearchService
 
         $filtered->load('profileMedia');
 
+        return [
+            'providers'    => $this->mapProviders($filtered),
+            'has_location' => true,
+        ];
+    }
+
+    private function mapProviders(Collection $filtered): array
+    {
         return $filtered->map(function ($item) {
             $arr = is_array($item) ? $item : $item->toArray();
 
@@ -275,21 +297,6 @@ class SearchService
         return $rate > 1 ? $rate / 100 : $rate;
     }
 
-    private function addressForDistance(int $clientId, ?Address $primaryAddress): ?Address
-    {
-        if ($primaryAddress?->latitude !== null && $primaryAddress?->longitude !== null) {
-            return $primaryAddress;
-        }
-
-        return Address::where('source', 'client')
-            ->where('source_id', $clientId)
-            ->whereNotNull('latitude')
-            ->whereNotNull('longitude')
-            ->orderByDesc('is_primary')
-            ->orderByDesc('id')
-            ->first() ?? $primaryAddress;
-    }
-
     private function distanceSelect(?float $clientLatitude, ?float $clientLongitude): \Illuminate\Contracts\Database\Query\Expression
     {
         return DistanceService::sqlExpression($clientLatitude, $clientLongitude);

+ 8 - 0
app/Services/UserService.php

@@ -118,6 +118,14 @@ class UserService
                 $client->save();
             }
 
+            if (array_key_exists('first_access', $data)) {
+                $client = $user->client ?? Client::create(['user_id' => $user->id]);
+
+                $client->first_access = data_get($data, 'first_access');
+
+                $client->save();
+            }
+
             if (data_get($data, 'avatar') !== null && data_get($data, 'avatar') instanceof UploadedFile) {
                 $client = $user->client ?? Client::create(['user_id' => $user->id]);
 

+ 28 - 0
database/migrations/2026_08_24_000000_add_first_access_to_clients_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('clients', function (Blueprint $table) {
+            $table->boolean('first_access')->default(true)->after('selfie_verified');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('clients', function (Blueprint $table) {
+            $table->dropColumn('first_access');
+        });
+    }
+};

+ 2 - 0
routes/authRoutes/dashboard.php

@@ -7,6 +7,8 @@ Route::get('/dados-dashboard-cliente', [DashboardController::class, 'dadosDashbo
 
 Route::get('/dados-dashboard-cliente/schedule/{id}/detalhes', [DashboardController::class, 'scheduleClienteDetails'])->middleware('permission:dashboard,view');
 
+Route::get('/dados-pedidos-cliente', [DashboardController::class, 'dadosPedidosCliente'])->middleware('permission:dashboard,view');
+
 Route::get('/dados-dashboard-prestador', [DashboardController::class, 'dadosDashboardPrestador'])
     ->middleware('permission:dashboard,view')
     ->withoutMiddleware('provider.accepted');

+ 3 - 0
routes/authRoutes/payment.php

@@ -10,6 +10,9 @@ Route::get('/payment/platform-fees',                           [PaymentControlle
 Route::post('/payment/service-package/{servicePackageId}/pay', [PaymentController::class, 'payServicePackage'])->middleware('permission:config.schedule,edit');
 Route::get('/payment/service-package/{servicePackage}/pix',    [PaymentController::class, 'getServicePackagePix'])->middleware('permission:config.schedule,view');
 
+Route::post('/payment/schedule-proposal/{proposal}/pay', [PaymentController::class, 'payScheduleProposal'])->middleware('permission:config.schedule,edit');
+Route::get('/payment/schedule-proposal/{proposal}/pix',  [PaymentController::class, 'getScheduleProposalPix'])->middleware('permission:config.schedule,view');
+
 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');

+ 7 - 0
routes/console.php

@@ -1,5 +1,6 @@
 <?php
 
+use App\Commands\ConfirmarPagamento;
 use App\Commands\CreateCrud;
 use App\Commands\RefreshPagarmeEntities;
 use App\Commands\RefreshPermissions;
@@ -46,6 +47,12 @@ Artisan::command('websocket:test {room} {--event=test-event} {--data=}', functio
     ]);
 })->purpose('Test websocket broadcasting by emitting an event');
 
+Artisan::command('confirma_pagamento {payment_id?}', function () {
+    $this->call(ConfirmarPagamento::class, [
+        'payment_id' => $this->argument('payment_id'),
+    ]);
+})->purpose('[LOCAL] Simula a confirmacao de pagamento do ultimo agendamento solicitado');
+
 //
 
 Artisan::command('pagarme:recipient-balance {recipient_id} {--skip-local}', function (