浏览代码

Merge branch 'development' into feature/diaria-kay-notificação-chamativa

Gustavo Zanatta 1 天之前
父节点
当前提交
57eaa97ff8
共有 52 个文件被更改,包括 2212 次插入376 次删除
  1. 0 1
      .env.example
  2. 4 2
      app/Commands/RefreshPagarmeEntities.php
  3. 1 0
      app/Enums/PushNotificationCategoryEnum.php
  4. 20 0
      app/Exceptions/BankAccountException.php
  5. 45 4
      app/Http/Controllers/ChatbotController.php
  6. 80 0
      app/Http/Controllers/ProviderBankAccountController.php
  7. 0 22
      app/Http/Controllers/ProviderController.php
  8. 0 7
      app/Http/Controllers/ProviderWithdrawalController.php
  9. 14 0
      app/Http/Controllers/SearchController.php
  10. 0 31
      app/Http/Requests/ProviderBankAccountRequest.php
  11. 27 0
      app/Http/Requests/ProviderBankAccountStoreRequest.php
  12. 28 0
      app/Http/Requests/ProviderBankAccountUpdateRequest.php
  13. 0 51
      app/Http/Requests/ProviderPaymentMethodRequest.php
  14. 4 4
      app/Http/Requests/ProviderRequest.php
  15. 4 5
      app/Http/Requests/RegisterProviderRequest.php
  16. 46 0
      app/Http/Resources/ProviderBankAccountResource.php
  17. 0 23
      app/Http/Resources/ProviderPaymentMethodResource.php
  18. 4 0
      app/Http/Resources/ProviderResource.php
  19. 8 1
      app/Http/Resources/ScheduleResource.php
  20. 3 1
      app/Http/Resources/ServicePackageResource.php
  21. 33 4
      app/Models/Provider.php
  22. 151 0
      app/Models/ProviderBankAccount.php
  23. 60 0
      app/Notifications/Push/Prestador/Transacional/CadastroAprovadoPush.php
  24. 1 1
      app/Services/AuthService.php
  25. 2 3
      app/Services/ClientFavoriteProviderService.php
  26. 30 9
      app/Services/DashboardService.php
  27. 16 1
      app/Services/GeminiService.php
  28. 11 2
      app/Services/Pagarme/PagarmePaymentService.php
  29. 51 2
      app/Services/Pagarme/PagarmeRecipientService.php
  30. 45 5
      app/Services/PaymentService.php
  31. 374 0
      app/Services/ProviderBankAccountService.php
  32. 71 83
      app/Services/ProviderService.php
  33. 6 2
      app/Services/ProviderWithdrawalService.php
  34. 42 0
      app/Services/ScheduleService.php
  35. 51 0
      app/Services/SearchService.php
  36. 1 1
      app/Services/UserService.php
  37. 1 1
      config/services.php
  38. 0 5
      config/withdrawals.php
  39. 128 0
      database/migrations/2026_08_12_100000_create_provider_bank_accounts_table.php
  40. 11 0
      lang/en/messages.php
  41. 11 0
      lang/es/messages.php
  42. 11 0
      lang/pt/messages.php
  43. 4 2
      phpunit.xml
  44. 156 54
      resources/chatbot/context_cliente.txt
  45. 99 33
      resources/chatbot/context_prestador.txt
  46. 39 8
      resources/chatbot/restricoes.md
  47. 3 1
      routes/authRoutes/dashboard.php
  48. 3 6
      routes/authRoutes/provider.php
  49. 14 0
      routes/authRoutes/provider_bank_account.php
  50. 2 1
      routes/authRoutes/search.php
  51. 206 0
      tests/Feature/ProviderApprovalPushTest.php
  52. 291 0
      tests/Feature/ProviderBankAccountTest.php

+ 0 - 1
.env.example

@@ -41,7 +41,6 @@ FIREBASE_CREDENTIALS=/var/www/sfp_api_laravel_diarista/storage/diariaappsfp-fire
 FIREBASE_PROJECT=diariaappsfp
 FIREBASE_CACHE_STORE=redis
 
-PIX_WITHDRAWALS_ENABLED=false
 PROVIDER_DAILY_PRICE_MIN=18
 SMS_ENABLED=false
 

+ 4 - 2
app/Commands/RefreshPagarmeEntities.php

@@ -76,7 +76,7 @@ class RefreshPagarmeEntities extends Command
 
         $providers = Provider::query()
             ->whereNotNull('recipient_id')
-            ->with(['user', 'addresses.city.state', 'addresses.state'])
+            ->with(['user', 'addresses.city.state', 'addresses.state', 'primaryBankAccount'])
             ->get();
 
         $bar = $this->output->createProgressBar($providers->count());
@@ -94,7 +94,9 @@ class RefreshPagarmeEntities extends Command
                 'recipient_payment_mode'         => $provider->recipient_payment_mode,
                 'recipient_description'          => $provider->recipient_description,
                 'recipient_metadata'             => $provider->recipient_metadata ?? [],
-                'recipient_default_bank_account' => $provider->recipient_default_bank_account ?? [],
+                'recipient_default_bank_account' => $provider->primaryBankAccount?->toGatewayBankAccount()
+                    ?? $provider->recipient_default_bank_account
+                    ?? [],
                 'birth_date'                     => $provider->birth_date?->format('Y-m-d'),
                 'professional_occupation'        => 'autonomo',
                 'phone'                          => $provider->user?->phone,

+ 1 - 0
app/Enums/PushNotificationCategoryEnum.php

@@ -13,4 +13,5 @@ enum PushNotificationCategoryEnum: string
     case SOCIAL_PROOF         = 'social_proof';
     case CONTEXTUAL           = 'contextual';
     case AGENDA               = 'agenda';
+    case TRANSACIONAL         = 'transacional';
 }

+ 20 - 0
app/Exceptions/BankAccountException.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Exceptions;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use RuntimeException;
+
+class BankAccountException extends RuntimeException
+{
+    public function __construct(string $messageKey, private readonly int $status = 422)
+    {
+        parent::__construct(__("messages.{$messageKey}"));
+    }
+
+    public function render(Request $request): JsonResponse
+    {
+        return response()->json(['payload' => null, 'message' => $this->getMessage()], $this->status);
+    }
+}

+ 45 - 4
app/Http/Controllers/ChatbotController.php

@@ -9,17 +9,27 @@ use Illuminate\Support\Facades\Auth;
 
 class ChatbotController extends Controller
 {
+    private const MESSAGE_MAX_LENGTH = 4000;
+
+    private const HISTORY_TEXT_MAX_LENGTH = 20000;
+
+    private const HISTORY_MAX_ENTRIES = 200;
+
+    private const HISTORY_TOTAL_MAX_LENGTH = 40000;
+
     public function __construct(
         private readonly GeminiService $geminiService,
     ) {}
 
     public function message(Request $request): JsonResponse
     {
+        $this->applyHistoryWindow($request);
+
         $validated = $request->validate([
-            'message'         => ['required', 'string', 'max:1000'],
-            'history'         => ['sometimes', 'array', 'max:20'],
-            'history.*.role'  => ['required_with:history', 'string', 'in:user,model'],
-            'history.*.text'  => ['required_with:history', 'string', 'max:1000'],
+            'message'        => ['required', 'string', 'max:' . self::MESSAGE_MAX_LENGTH],
+            'history'        => ['sometimes', 'array', 'max:' . self::HISTORY_MAX_ENTRIES],
+            'history.*.role' => ['required_with:history', 'string', 'in:user,model'],
+            'history.*.text' => ['required_with:history', 'string', 'max:' . self::HISTORY_TEXT_MAX_LENGTH],
         ]);
 
         $user = Auth::user();
@@ -32,4 +42,35 @@ class ChatbotController extends Controller
 
         return $this->successResponse(payload: ['reply' => $reply]);
     }
+
+    //
+
+    private function applyHistoryWindow(Request $request): void
+    {
+        $history = $request->input('history');
+
+        if (! is_array($history)) {
+            return;
+        }
+
+        $window = [];
+        $total  = 0;
+
+        foreach (array_reverse($history) as $entry) {
+            $text   = data_get($entry, 'text');
+            $length = is_string($text) ? mb_strlen($text) : 0;
+
+            $exceedsEntries = count($window) >= self::HISTORY_MAX_ENTRIES;
+            $exceedsLength  = $window !== [] && $total + $length > self::HISTORY_TOTAL_MAX_LENGTH;
+
+            if ($exceedsEntries || $exceedsLength) {
+                break;
+            }
+
+            $window[] = $entry;
+            $total += $length;
+        }
+
+        $request->merge(['history' => array_reverse($window)]);
+    }
 }

+ 80 - 0
app/Http/Controllers/ProviderBankAccountController.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\ProviderBankAccountStoreRequest;
+use App\Http\Requests\ProviderBankAccountUpdateRequest;
+use App\Http\Resources\ProviderBankAccountResource;
+use App\Models\Provider;
+use App\Services\ProviderBankAccountService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Auth;
+
+class ProviderBankAccountController extends Controller
+{
+    public function __construct(
+        private readonly ProviderBankAccountService $service,
+    ) {}
+
+    public function index(): JsonResponse
+    {
+        $items = $this->service->getAll($this->resolveProvider());
+
+        return $this->successResponse(payload: ProviderBankAccountResource::collection($items));
+    }
+
+    public function store(ProviderBankAccountStoreRequest $request): JsonResponse
+    {
+        $item = $this->service->create($this->resolveProvider(), $request->validated());
+
+        return $this->successResponse(
+            payload: new ProviderBankAccountResource($item),
+            message: __('messages.bank_account_created'),
+            code:    201,
+        );
+    }
+
+    public function update(ProviderBankAccountUpdateRequest $request, int $id): JsonResponse
+    {
+        $item = $this->service->update($this->resolveProvider(), $id, $request->validated());
+
+        return $this->successResponse(
+            payload: new ProviderBankAccountResource($item),
+            message: __('messages.bank_account_updated'),
+        );
+    }
+
+    public function destroy(int $id): JsonResponse
+    {
+        $this->service->delete($this->resolveProvider(), $id);
+
+        return $this->successResponse(message: __('messages.bank_account_deleted'));
+    }
+
+    //
+
+    /**
+     * Somente o proprio prestador manipula as contas: o id nunca vem da URL.
+     */
+    public function forProvider(int $providerId): JsonResponse
+    {
+        $provider = Provider::findOrFail($providerId);
+
+        $items = $this->service->getAll($provider);
+
+        return $this->successResponse(payload: ProviderBankAccountResource::collection($items));
+    }
+
+    //
+
+    private function resolveProvider(): Provider
+    {
+        $provider = Auth::user()?->provider;
+
+        if (! $provider) {
+            abort(403, __('auth.unauthorized'));
+        }
+
+        return $provider;
+    }
+}

+ 0 - 22
app/Http/Controllers/ProviderController.php

@@ -2,7 +2,6 @@
 
 namespace App\Http\Controllers;
 
-use App\Http\Requests\ProviderBankAccountRequest;
 use App\Http\Requests\ProviderRequest;
 use App\Http\Requests\RegisterProviderRequest;
 use App\Http\Resources\AuthResource;
@@ -79,15 +78,6 @@ class ProviderController extends Controller
         ]);
     }
 
-    public function paymentMethods(int $id): JsonResponse
-    {
-        return $this->successResponse(
-            payload: $this->service->getPaymentMethods($id),
-        );
-    }
-
-    //
-
     public function approve(int $id): JsonResponse
     {
         $item = $this->service->approve($id);
@@ -127,18 +117,6 @@ class ProviderController extends Controller
         );
     }
 
-    public function updateBankAccount(ProviderBankAccountRequest $request, int $id): JsonResponse
-    {
-        $item = $this->service->updateBankAccount($id, $request->validated()['recipient_default_bank_account']);
-
-        return $this->successResponse(
-            payload: new ProviderResource($item),
-            message: __('messages.updated'),
-        );
-    }
-
-    //
-
     public function dailyPriceMin(): JsonResponse
     {
         return $this->successResponse(payload: [

+ 0 - 7
app/Http/Controllers/ProviderWithdrawalController.php

@@ -65,13 +65,6 @@ class ProviderWithdrawalController extends Controller
         ]);
     }
 
-    public function configuration(): JsonResponse
-    {
-        return $this->successResponse(payload: [
-            'pix_enabled' => config('withdrawals.pix_enabled'),
-        ]);
-    }
-
     public function fees(): JsonResponse
     {
         return $this->successResponse(payload: $this->service->getWithdrawalFees());

+ 14 - 0
app/Http/Controllers/SearchController.php

@@ -3,6 +3,7 @@
 namespace App\Http\Controllers;
 
 use App\Services\SearchService;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 
@@ -20,4 +21,17 @@ class SearchController extends Controller
             return $this->errorResponse(message: __('messages.error_fetching_data'), code: 500, exception: $e);
         }
     }
+
+    public function perfilPrestador(int $id): JsonResponse
+    {
+        try {
+            $dados = $this->service->perfilPrestador($id);
+
+            return $this->successResponse(payload: $dados);
+        } catch (ModelNotFoundException $e) {
+            return $this->errorResponse(message: __('messages.not_found'), code: 404, exception: $e);
+        } catch (\Exception $e) {
+            return $this->errorResponse(message: __('messages.error_fetching_data'), code: 500, exception: $e);
+        }
+    }
 }

+ 0 - 31
app/Http/Requests/ProviderBankAccountRequest.php

@@ -1,31 +0,0 @@
-<?php
-
-namespace App\Http\Requests;
-
-use Illuminate\Foundation\Http\FormRequest;
-use Illuminate\Validation\Rule;
-
-class ProviderBankAccountRequest extends FormRequest
-{
-    public function authorize(): bool
-    {
-        return true;
-    }
-
-    public function rules(): array
-    {
-        return [
-            'recipient_default_bank_account'                     => ['required', 'array'],
-            'recipient_default_bank_account.holder_name'         => ['sometimes', 'nullable', 'string', 'max:255'],
-            'recipient_default_bank_account.holder_type'         => ['sometimes', 'nullable', Rule::in(['individual', 'company'])],
-            'recipient_default_bank_account.holder_document'     => ['sometimes', 'nullable', 'string', 'max:20'],
-            'recipient_default_bank_account.bank'                => ['required', 'string', 'max:20'],
-            'recipient_default_bank_account.branch_number'       => ['required', 'string', 'max:20'],
-            'recipient_default_bank_account.branch_check_digit'  => ['sometimes', 'nullable', 'string', 'max:10'],
-            'recipient_default_bank_account.account_number'      => ['required', 'string', 'max:13'],
-            'recipient_default_bank_account.account_check_digit' => ['required', 'string', 'max:10'],
-            'recipient_default_bank_account.type'                => ['required', Rule::in(['checking', 'savings'])],
-            'recipient_default_bank_account.metadata'            => ['sometimes', 'array'],
-        ];
-    }
-}

+ 27 - 0
app/Http/Requests/ProviderBankAccountStoreRequest.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class ProviderBankAccountStoreRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+        return [
+            'bank_id'             => ['required_without:bank_code', 'nullable', 'integer', Rule::exists('banks', 'id')->where('active', true)->whereNull('deleted_at')],
+            'bank_code'           => ['required_without:bank_id', 'nullable', 'string', 'max:3'],
+            'type'                => ['required', Rule::in(['checking', 'savings'])],
+            'branch_number'       => ['required', 'string', 'max:4'],
+            'branch_check_digit'  => ['sometimes', 'nullable', 'string', 'max:2'],
+            'account_number'      => ['required', 'string', 'max:13'],
+            'account_check_digit' => ['required', 'string', 'max:2'],
+        ];
+    }
+}

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

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class ProviderBankAccountUpdateRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+        return [
+            'bank_id'             => ['sometimes', 'nullable', 'integer', Rule::exists('banks', 'id')->where('active', true)->whereNull('deleted_at')],
+            'bank_code'           => ['sometimes', 'nullable', 'string', 'max:3'],
+            'type'                => ['sometimes', Rule::in(['checking', 'savings'])],
+            'branch_number'       => ['sometimes', 'string', 'max:4'],
+            'branch_check_digit'  => ['sometimes', 'nullable', 'string', 'max:2'],
+            'account_number'      => ['sometimes', 'string', 'max:13'],
+            'account_check_digit' => ['sometimes', 'string', 'max:2'],
+            'is_primary'          => ['sometimes', 'boolean'],
+        ];
+    }
+}

+ 0 - 51
app/Http/Requests/ProviderPaymentMethodRequest.php

@@ -1,51 +0,0 @@
-<?php
-
-namespace App\Http\Requests;
-
-use App\Enums\AccountTypeEnum;
-use App\Enums\BankAccountTypeEnum;
-use Illuminate\Foundation\Http\FormRequest;
-
-class ProviderPaymentMethodRequest extends FormRequest
-{
-    public function authorize(): bool
-    {
-        return true;
-    }
-
-    public function rules(): array
-    {
-        $pixAccountType   = AccountTypeEnum::PIX->value;
-        $bankAccountType  = AccountTypeEnum::BANK_ACCOUNT->value;
-        $accountTypes     = implode(',', [$pixAccountType, $bankAccountType]);
-        $bankAccountTypes = implode(',', [BankAccountTypeEnum::CHECKING->value, BankAccountTypeEnum::SAVINGS->value]);
-
-        if ($this->account_type == AccountTypeEnum::PIX->value) {
-
-            $rules =  [
-                'account_type'      => "sometimes|in:{$accountTypes}",
-                'pix_key'           => "required|string|max:255|required_if:account_type,{$pixAccountType}",
-                'bank_account_type' => "nullable|required_if:account_type,{$bankAccountType}|in:{$bankAccountTypes}",
-                'agency'            => "nullable|string|max:255|required_if:account_type,{$bankAccountType}",
-                'account'           => "nullable|string|max:255|required_if:account_type,{$bankAccountType}",
-                'digit'             => "nullable|string|max:255|required_if:account_type,{$bankAccountType}",
-            ];
-        } else {
-            $rules =  [
-                'account_type'      => "sometimes|in:{$accountTypes}",
-                'pix_key'           => "nullable|string|max:255|required_if:account_type,{$pixAccountType}",
-                'bank_account_type' => "sometimes|required_if:account_type,{$bankAccountType}|in:{$bankAccountTypes}",
-                'agency'            => "sometimes|string|max:255|required_if:account_type,{$bankAccountType}",
-                'account'           => "sometimes|string|max:255|required_if:account_type,{$bankAccountType}",
-                'digit'             => "sometimes|string|max:255|required_if:account_type,{$bankAccountType}",
-            ];
-        }
-
-        if ($this->isMethod('post')) {
-            $rules['provider_id'] = 'required|exists:providers,id';
-        };
-
-
-        return $rules;
-    }
-}

+ 4 - 4
app/Http/Requests/ProviderRequest.php

@@ -115,10 +115,10 @@ class ProviderRequest extends FormRequest
             $rules['recipient_default_bank_account.holder_type']         = ['required', Rule::in(['individual', 'company'])];
             $rules['recipient_default_bank_account.holder_document']     = 'required|string|max:20';
             $rules['recipient_default_bank_account.bank']                = 'required|string|max:3';
-            $rules['recipient_default_bank_account.branch_number']       = 'required|string|max:20';
-            $rules['recipient_default_bank_account.branch_check_digit']  = 'sometimes|nullable|string|max:10';
-            $rules['recipient_default_bank_account.account_number']      = 'required|string|max:20';
-            $rules['recipient_default_bank_account.account_check_digit'] = 'required|string|max:10';
+            $rules['recipient_default_bank_account.branch_number']       = 'required|string|max:4';
+            $rules['recipient_default_bank_account.branch_check_digit']  = 'sometimes|nullable|string|max:2';
+            $rules['recipient_default_bank_account.account_number']      = 'required|string|max:13';
+            $rules['recipient_default_bank_account.account_check_digit'] = 'required|string|max:2';
             $rules['recipient_default_bank_account.type']                = ['required', Rule::in(['checking', 'savings'])];
             $rules['recipient_default_bank_account.metadata']            = 'required|array';
             $rules['recipient_metadata']                                 = 'required|array';

+ 4 - 5
app/Http/Requests/RegisterProviderRequest.php

@@ -68,13 +68,12 @@ class RegisterProviderRequest extends FormRequest
       'recipient_default_bank_account.holder_document'     => 'sometimes|string|max:20',
       'recipient_default_bank_account.holder_type'         => 'sometimes|string|in:individual,company',
       'recipient_default_bank_account.bank'                => 'sometimes|string|max:3',
-      'recipient_default_bank_account.branch_number'       => 'sometimes|string|max:20',
-      'recipient_default_bank_account.branch_check_digit'  => 'sometimes|string|max:10',
-      'recipient_default_bank_account.account_number'      => 'sometimes|string|max:20',
-      'recipient_default_bank_account.account_check_digit' => 'sometimes|string|max:10',
+      'recipient_default_bank_account.branch_number'       => 'sometimes|string|max:4',
+      'recipient_default_bank_account.branch_check_digit'  => 'sometimes|string|nullable|max:2',
+      'recipient_default_bank_account.account_number'      => 'sometimes|string|max:13',
+      'recipient_default_bank_account.account_check_digit' => 'sometimes|string|max:2',
       'recipient_default_bank_account.type'                => 'sometimes|string|in:checking,savings',
       'recipient_default_bank_account.metadata'            => 'sometimes|array',
-      'recipient_default_bank_account.pix_key'             => 'sometimes|string|nullable|max:255',
 
       'recipient_metadata' => 'sometimes|array',
       

+ 46 - 0
app/Http/Resources/ProviderBankAccountResource.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Resources;
+
+use App\Models\ProviderBankAccount;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class ProviderBankAccountResource extends JsonResource
+{
+    /**
+     * @return array<string, mixed>
+     */
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                  => $this->id,
+            'provider_id'         => $this->provider_id,
+            'bank_id'             => $this->bank_id,
+            'bank_code'           => $this->bank_code,
+            'bank_name'           => $this->bank?->name ?? $this->bank_name,
+            'holder_name'         => $this->holder_name,
+            'holder_document'     => $this->holder_document,
+            'type'                => $this->type,
+            'branch_number'       => $this->branch_number,
+            'branch_check_digit'  => $this->branch_check_digit,
+            'account_number'      => $this->account_number,
+            'account_check_digit' => $this->account_check_digit,
+            'is_primary'          => $this->is_primary,
+            'is_active'           => $this->is_active,
+            'synced'              => $this->isSyncedWithGateway(),
+            'created_at'          => $this->created_at?->format('d/m/Y H:i'),
+            'updated_at'          => $this->updated_at?->format('d/m/Y H:i'),
+        ];
+    }
+
+    /**
+     * @param \Illuminate\Database\Eloquent\Collection<ProviderBankAccount> $resource
+     * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection<ProviderBankAccountResource>
+     */
+    public static function collection($resource): AnonymousResourceCollection
+    {
+        return parent::collection($resource);
+    }
+}

+ 0 - 23
app/Http/Resources/ProviderPaymentMethodResource.php

@@ -1,23 +0,0 @@
-<?php
-
-namespace App\Http\Resources;
-
-use Illuminate\Http\Request;
-use Illuminate\Http\Resources\Json\JsonResource;
-
-class ProviderPaymentMethodResource extends JsonResource
-{
-    public function toArray(Request $request): array
-    {
-        return [
-            'id'                => $this->id,
-            'provider_id'       => $this->provider_id,
-            'account_type'      => $this->account_type?->value,
-            'pix_key'           => $this->pix_key,
-            'bank_account_type' => $this->bank_account_type?->value,
-            'agency'            => $this->agency,
-            'account'           => $this->account,
-            'digit'             => $this->digit,
-        ];
-    }
-}

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

@@ -33,6 +33,10 @@ class ProviderResource extends JsonResource
             'profile_media_id'               => $this->profileMedia?->id,
             'recipient_id'                   => $this->recipient_id,
             'recipient_default_bank_account' => $this->recipient_default_bank_account,
+            'has_primary_bank_account'       => $this->relationLoaded('primaryBankAccount')
+                ? $this->primaryBankAccount !== null
+                : $this->primaryBankAccount()->exists(),
+            'bank_accounts'                  => ProviderBankAccountResource::collection($this->whenLoaded('bankAccounts')),
             'profile_media'                  => $this->profileMedia ? new MediaResource($this->profileMedia) : null,
             'document_front_media'           => $this->whenLoaded('documentFrontMedia', fn () => $this->documentFrontMedia ? new MediaResource($this->documentFrontMedia) : null),
             'document_back_media'            => $this->whenLoaded('documentBackMedia', fn () => $this->documentBackMedia ? new MediaResource($this->documentBackMedia) : null),

+ 8 - 1
app/Http/Resources/ScheduleResource.php

@@ -15,7 +15,14 @@ class ScheduleResource extends JsonResource
             ->value('service_package_id');
 
         $servicePackageItemsCount = $this->service_package_items_count ?? (
-            $servicePackageId ? ServicePackageItem::query()->where('service_package_id', $servicePackageId)->count() : 0
+            $servicePackageId
+                ? ServicePackageItem::query()
+                    ->where('service_package_id', $servicePackageId)
+                    ->whereHas('schedule', fn ($query) => $query
+                        ->whereNotIn('status', ['cancelled', 'rejected'])
+                        ->where('schedule_type', 'default'))
+                    ->count()
+                : 0
         );
 
         return [

+ 3 - 1
app/Http/Resources/ServicePackageResource.php

@@ -30,7 +30,9 @@ class ServicePackageResource extends JsonResource
             )),
 
             'total_amount' => $this->whenLoaded('items', fn () =>
-                (float) $this->items->sum(fn($item) => (float) ($item->schedule?->total_amount ?? 0))
+                (float) $this->items
+                    ->filter(fn($item) => $item->schedule && ! in_array($item->schedule->status, ['cancelled', 'rejected'], true))
+                    ->sum(fn($item) => (float) $item->schedule->total_amount)
             ),
 
             'created_at'   => $this->created_at?->toISOString(),

+ 33 - 4
app/Models/Provider.php

@@ -193,6 +193,21 @@ class Provider extends Model
 
     //
 
+    public function bankAccounts(): HasMany
+    {
+        return $this->hasMany(ProviderBankAccount::class)
+            ->orderBy('is_primary', 'desc')
+            ->orderBy('created_at', 'asc');
+    }
+
+    public function primaryBankAccount(): HasOne
+    {
+        return $this->hasOne(ProviderBankAccount::class)
+            ->where('is_primary', true);
+    }
+
+    //
+
     public function blockedByClients()
     {
         return $this->hasMany(ClientProviderBlock::class);
@@ -242,9 +257,23 @@ class Provider extends Model
 
     public function scopeVisibleToCustomers($query)
     {
-        return $query
-            ->whereNotNull('providers.recipient_default_bank_account')
-            ->whereRaw("providers.recipient_default_bank_account::text <> '{}'")
-            ->whereRaw("providers.recipient_default_bank_account::text <> '[]'");
+        return $query->whereExists(self::hasActivePrimaryBankAccount());
+    }
+
+    /**
+     * Prestador so aparece para os clientes quando tem conta bancaria principal ativa.
+     *
+     * Devolvido como closure para poder ser usado tambem em queries que chegam em
+     * providers por join (favoritos, propostas), e nao apenas pelo model.
+     */
+    public static function hasActivePrimaryBankAccount(): \Closure
+    {
+        return fn ($query) => $query
+            ->select(\Illuminate\Support\Facades\DB::raw(1))
+            ->from('provider_bank_accounts')
+            ->whereColumn('provider_bank_accounts.provider_id', 'providers.id')
+            ->where('provider_bank_accounts.is_primary', true)
+            ->where('provider_bank_accounts.is_active', true)
+            ->whereNull('provider_bank_accounts.deleted_at');
     }
 }

+ 151 - 0
app/Models/ProviderBankAccount.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * @property int $id
+ * @property int $provider_id
+ * @property int|null $bank_id
+ * @property string $bank_code
+ * @property string|null $bank_name
+ * @property string $holder_name
+ * @property string $holder_document
+ * @property string $holder_type
+ * @property string $type
+ * @property string $branch_number
+ * @property string|null $branch_check_digit
+ * @property string $account_number
+ * @property string $account_check_digit
+ * @property bool $is_primary
+ * @property bool $is_active
+ * @property \Illuminate\Support\Carbon|null $gateway_synced_at
+ * @property string|null $gateway_sync_hash
+ * @property array|null $gateway_payload
+ * @property \Illuminate\Support\Carbon|null $created_at
+ * @property \Illuminate\Support\Carbon|null $updated_at
+ * @property \Illuminate\Support\Carbon|null $deleted_at
+ * @property-read Bank|null $bank
+ * @property-read Provider $provider
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount newModelQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount newQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount onlyTrashed()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount query()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount active()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount primary()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount withTrashed(bool $withTrashed = true)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ProviderBankAccount withoutTrashed()
+ * @mixin \Eloquent
+ */
+class ProviderBankAccount extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $table = 'provider_bank_accounts';
+
+    protected $fillable = [
+        'provider_id',
+        'bank_id',
+        'bank_code',
+        'bank_name',
+        'holder_name',
+        'holder_document',
+        'holder_type',
+        'type',
+        'branch_number',
+        'branch_check_digit',
+        'account_number',
+        'account_check_digit',
+        'is_primary',
+        'is_active',
+        'gateway_synced_at',
+        'gateway_sync_hash',
+        'gateway_sync_count',
+        'gateway_payload',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'is_primary'         => 'boolean',
+            'is_active'          => 'boolean',
+            'gateway_synced_at'  => 'datetime',
+            'gateway_sync_count' => 'integer',
+            'gateway_payload'    => 'array',
+        ];
+    }
+
+    public function provider(): BelongsTo
+    {
+        return $this->belongsTo(Provider::class);
+    }
+
+    public function bank(): BelongsTo
+    {
+        return $this->belongsTo(Bank::class);
+    }
+
+    //
+
+    public function scopePrimary(Builder $query): Builder
+    {
+        return $query->where('is_primary', true);
+    }
+
+    public function scopeActive(Builder $query): Builder
+    {
+        return $query->where('is_active', true);
+    }
+
+    //
+
+    /**
+     * Formato esperado pelo Pagar.me em default_bank_account / bank_account.
+     * E tambem o formato espelhado em providers.recipient_default_bank_account.
+     */
+    public function toGatewayBankAccount(): array
+    {
+        return [
+            'holder_name'         => $this->holder_name,
+            'holder_type'         => $this->holder_type,
+            'holder_document'     => $this->holder_document,
+            'bank'                => $this->bank_code,
+            'branch_number'       => $this->branch_number,
+            'branch_check_digit'  => $this->branch_check_digit,
+            'account_number'      => $this->account_number,
+            'account_check_digit' => $this->account_check_digit,
+            'type'                => $this->type,
+        ];
+    }
+
+    /**
+     * Identifica o conteudo da conta enviado ao gateway, para detectar divergencia
+     * entre o que esta salvo aqui e o que o Pagar.me tem como conta padrao.
+     */
+    public function gatewayHash(): string
+    {
+        return sha1(json_encode($this->toGatewayBankAccount()));
+    }
+
+    /**
+     * Avanca o contador usado na chave de idempotencia, para que cada envio ao Pagar.me
+     * seja uma operacao nova - inclusive ao voltar para uma conta ja usada antes.
+     */
+    public function markGatewaySyncAttempt(): int
+    {
+        $this->forceFill(['gateway_sync_count' => $this->gateway_sync_count + 1])->save();
+
+        return $this->gateway_sync_count;
+    }
+
+    public function isSyncedWithGateway(): bool
+    {
+        return $this->gateway_synced_at !== null
+            && $this->gateway_sync_hash === $this->gatewayHash();
+    }
+}

+ 60 - 0
app/Notifications/Push/Prestador/Transacional/CadastroAprovadoPush.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Notifications\Push\Prestador\Transacional;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+/**
+ * Push transacional disparada no momento em que o cadastro do prestador é aprovado.
+ *
+ * Não é registrada no PushNotificationDispatcher: o envio parte do
+ * ProviderService, não da varredura do scheduler.
+ */
+class CadastroAprovadoPush extends BasePushNotification
+{
+    public function label(): string
+    {
+        return 'provider_cadastro_aprovado';
+    }
+
+    public function title(): string
+    {
+        return 'Cadastro aprovado! 🎉';
+    }
+
+    public function body(): string
+    {
+        return 'Boas-vindas ao diária app. Seu perfil foi aprovado e você já pode receber diárias.';
+    }
+
+    public function target(): PushNotificationTargetEnum
+    {
+        return PushNotificationTargetEnum::PRESTADOR;
+    }
+
+    public function category(): PushNotificationCategoryEnum
+    {
+        return PushNotificationCategoryEnum::TRANSACIONAL;
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
+    }
+
+    /**
+     * Nunca é elegível por varredura — o disparo é sempre pontual.
+     */
+    public function eligibleUsers(): Collection
+    {
+        return new Collection;
+    }
+}

+ 1 - 1
app/Services/AuthService.php

@@ -385,7 +385,7 @@ class AuthService
 
     public function createAppSession(User $user): array
     {
-        $user->loadMissing(['provider.profileMedia', 'client.profileMedia']);
+        $user->loadMissing(['provider.profileMedia', 'provider.primaryBankAccount', 'client.profileMedia']);
 
         $deviceId = Str::uuid()->toString();
 

+ 2 - 3
app/Services/ClientFavoriteProviderService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Http\Resources\ClientFavoriteProviderResource;
 use App\Models\ClientFavoriteProvider;
+use App\Models\Provider;
 use Illuminate\Database\Eloquent\Collection;
 
 class ClientFavoriteProviderService
@@ -13,9 +14,7 @@ class ClientFavoriteProviderService
         return ClientFavoriteProvider::where('client_favorite_providers.client_id', $clientId)
             ->with('provider.profileMedia')
             ->leftJoin('providers', 'providers.id', '=', 'client_favorite_providers.provider_id')
-            ->whereNotNull('providers.recipient_default_bank_account')
-            ->whereRaw("providers.recipient_default_bank_account::text <> '{}'")
-            ->whereRaw("providers.recipient_default_bank_account::text <> '[]'")
+            ->whereExists(Provider::hasActivePrimaryBankAccount())
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
             ->leftJoin('addresses as provider_address', function ($join) {
                 $join->on('provider_address.source_id', '=', 'providers.id')

+ 30 - 9
app/Services/DashboardService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use App\Enums\ApprovalStatusEnum;
 use App\Enums\GenderEnum;
 use App\Enums\ServicePackageStatusEnum;
 use App\Enums\UserTypeEnum;
@@ -74,9 +75,8 @@ class DashboardService
             'address:district,address,source_id,source,id,address_type',
         ])
             ->where('schedules.client_id', $cliente->id)
-            ->whereIn('schedules.status', ['accepted', 'paid'])
+            ->where('schedules.status', 'paid')
             ->whereDate('schedules.date', '>=', now()->toDateString())
-            ->where('schedules.date', '>=', now()->toDateString())
             ->leftJoin('providers', 'providers.id', '=', 'schedules.provider_id')
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
             ->leftJoin('custom_schedules', 'custom_schedules.schedule_id', '=', 'schedules.id')
@@ -107,6 +107,9 @@ class DashboardService
                 (
                     SELECT COUNT(*)
                     FROM service_package_items spi_count
+                    JOIN schedules s_count ON s_count.id = spi_count.schedule_id
+                        AND s_count.status NOT IN ('cancelled', 'rejected')
+                        AND s_count.schedule_type = 'default'
                     WHERE spi_count.service_package_id = (
                         SELECT spi.service_package_id
                         FROM service_package_items spi
@@ -158,9 +161,7 @@ class DashboardService
 
         $favoriteProviders = ClientFavoriteProvider::where('client_favorite_providers.client_id', $cliente->id)
             ->leftJoin('providers', 'providers.id', '=', 'client_favorite_providers.provider_id')
-            ->whereNotNull('providers.recipient_default_bank_account')
-            ->whereRaw("providers.recipient_default_bank_account::text <> '{}'")
-            ->whereRaw("providers.recipient_default_bank_account::text <> '[]'")
+            ->whereExists(Provider::hasActivePrimaryBankAccount())
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
             ->leftJoinSub(
                 Address::preferredForProvider(),
@@ -330,6 +331,7 @@ class DashboardService
                 ->where('schedules.client_id', $cliente->id)
                 ->whereIn('schedules.status', ['pending', 'accepted'])
                 ->where('schedules.schedule_type', 'default')
+                ->whereDate('schedules.date', '>=', now()->toDateString())
                 ->leftJoin('providers', 'providers.id', '=', 'schedules.provider_id')
                 ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
                 ->select(
@@ -377,6 +379,9 @@ class DashboardService
                 (
                     SELECT COUNT(*)
                     FROM service_package_items spi_count
+                    JOIN schedules s_count ON s_count.id = spi_count.schedule_id
+                        AND s_count.status NOT IN ('cancelled', 'rejected')
+                        AND s_count.schedule_type = 'default'
                     WHERE spi_count.service_package_id = (
                         SELECT spi.service_package_id
                         FROM service_package_items spi
@@ -411,9 +416,7 @@ class DashboardService
                 '=',
                 'providers.id'
             )
-            ->whereNotNull('providers.recipient_default_bank_account')
-            ->whereRaw("providers.recipient_default_bank_account::text <> '{}'")
-            ->whereRaw("providers.recipient_default_bank_account::text <> '[]'")
+            ->whereExists(Provider::hasActivePrimaryBankAccount())
             ->leftJoin('users', 'providers.user_id', '=', 'users.id')
             ->leftJoin(
                 DB::raw("
@@ -534,6 +537,9 @@ class DashboardService
                 (
                     SELECT COUNT(*)
                     FROM service_package_items spi_count
+                    JOIN schedules s_count ON s_count.id = spi_count.schedule_id
+                        AND s_count.status NOT IN ('cancelled', 'rejected')
+                        AND s_count.schedule_type = 'default'
                     WHERE spi_count.service_package_id = (
                         SELECT spi.service_package_id
                         FROM service_package_items spi
@@ -605,7 +611,8 @@ class DashboardService
         $pendingServicePackages = ServicePackage::query()
             ->where('client_id', $cliente->id)
             ->where('status', ServicePackageStatusEnum::OPEN->value)
-            ->whereDoesntHave('items.schedule', fn($q) => $q->where('status', '!=', 'accepted'))
+            ->whereHas('items.schedule', fn($q) => $q->where('status', 'accepted'))
+            ->whereDoesntHave('items.schedule', fn($q) => $q->where('status', 'pending'))
             ->with(['items.schedule' => function ($query) {
                 $query->with(['provider.user', 'address']);
             }])
@@ -657,6 +664,20 @@ class DashboardService
                 : null,
         ];
 
+        if ($provider->approval_status === ApprovalStatusEnum::PENDING) {
+            return [
+                'headerBar'           => $headerBar,
+                'summaryInfos'        => $summaryInfos,
+                'priceSuggested'      => null,
+                'todayServices'       => [],
+                'solicitations'       => [],
+                'nextSchedules'       => [],
+                'opportunities'       => [],
+                'notifications'       => [],
+                'pendingConfirmation' => [],
+            ];
+        }
+
         $priceSuggestedAvg = Provider::where('user_id', '!=', $user->id)
             ->whereNotNull('daily_price_8h')
             ->pluck('daily_price_8h')

+ 16 - 1
app/Services/GeminiService.php

@@ -35,7 +35,8 @@ class GeminiService
 
             'generationConfig' => [
                 'temperature'     => 0.4,
-                'maxOutputTokens' => 512,
+                'maxOutputTokens' => 2048,
+                'thinkingConfig'  => ['thinkingBudget' => 0],
             ],
         ];
 
@@ -47,6 +48,8 @@ class GeminiService
 
             $data = json_decode($response->getBody()->getContents(), true);
 
+            $this->logTruncatedResponse($data, $model);
+
             return data_get($data, 'candidates.0.content.parts.0.text', __('chatbot.error_response'));
         } catch (GuzzleException $e) {
             $this->logGeminiError($e, $model);
@@ -99,6 +102,18 @@ class GeminiService
         return $restrictions . "\n\n" . $context;
     }
 
+    private function logTruncatedResponse(?array $data, string $model): void
+    {
+        if (data_get($data, 'candidates.0.finishReason') !== 'MAX_TOKENS') {
+            return;
+        }
+
+        Log::warning('Resposta do Gemini truncada por limite de tokens', [
+            'model'         => $model,
+            'usageMetadata' => data_get($data, 'usageMetadata'),
+        ]);
+    }
+
     private function logGeminiError(GuzzleException $e, string $model): void
     {
         $context = [

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

@@ -65,7 +65,9 @@ class PagarmePaymentService
 
     private function platformFeeRate(string $paymentMethod, ?Schedule $schedule = null): float
     {
-        if ($schedule && $this->scheduleBelongsToServicePackageWithAtLeastThreeItems($schedule)) {
+        if ($schedule
+            && $schedule->schedule_type !== 'custom'
+            && $this->scheduleBelongsToServicePackageWithAtLeastThreeItems($schedule)) {
             return $paymentMethod === 'credit_card'
                 ? $this->configuredPlatformFeeRate('platform_service_package_min_3_schedules_credit_card_fee_rate')
                 : $this->configuredPlatformFeeRate('platform_service_package_min_3_schedules_pix_fee_rate');
@@ -399,7 +401,14 @@ class PagarmePaymentService
     {
         return ServicePackage::query()
             ->whereHas('items', fn ($query) => $query->where('schedule_id', $schedule->id))
-            ->whereHas('items', null, '>=', 3)
+            ->whereHas(
+                'items.schedule',
+                fn ($query) => $query
+                    ->whereNotIn('status', ['cancelled', 'rejected'])
+                    ->where('schedule_type', 'default'),
+                '>=',
+                3,
+            )
             ->exists();
     }
 

+ 51 - 2
app/Services/Pagarme/PagarmeRecipientService.php

@@ -13,6 +13,7 @@ use App\Data\Pagarme\Recipient\Parts\Request\TransferSettingsData;
 use App\Data\Pagarme\Recipient\RecipientRequestData;
 use App\Data\Pagarme\Recipient\RecipientResponseData;
 use App\Models\Provider;
+use App\Models\ProviderBankAccount;
 use App\Services\Pagarme\Concerns\FormatsPagarmeData;
 use App\Services\Pagarme\Concerns\MocksPagarmeRequests;
 use App\Services\Pagarme\Concerns\SendsPagarmeRequests;
@@ -158,8 +159,37 @@ class PagarmeRecipientService
         return $recipientId;
     }
 
-    public function updateDefaultBankAccount(Provider $provider, array $bankAccountData): Provider
+    public function getRecipient(string $recipientId): array
     {
+        if ($this->shouldMockPagarmeRequest()) {
+            $provider = Provider::query()->where('recipient_id', $recipientId)->first();
+
+            return [
+                'id'                   => $recipientId,
+                'default_bank_account' => $provider?->recipient_default_bank_account ?? [],
+                'mocked'               => true,
+            ];
+        }
+
+        return $this->pagarmeRequest(
+            method:         'GET',
+            path:           "/recipients/{$recipientId}",
+            payload:        [],
+            idempotencyKey: "get-recipient-{$recipientId}",
+            errorMessage:   'Erro ao consultar recebedor no Pagar.me.',
+        );
+    }
+
+    /**
+     * O Pagar.me mantem UMA conta bancaria por recebedor. As demais contas do prestador
+     * existem apenas no nosso banco; aqui so trafega a conta principal.
+     *
+     * O recipient_id nao muda: o que e atualizado e o destino das transferencias (saques).
+     */
+    public function updateDefaultBankAccount(Provider $provider, ProviderBankAccount $bankAccount): Provider
+    {
+        $bankAccountData = $bankAccount->toGatewayBankAccount();
+
         $payload = new BankAccountUpdateRequestData(
             holderName:        $this->normalizeHolderName(data_get($bankAccountData, 'holder_name')),
             holderType:        data_get($bankAccountData, 'holder_type'),
@@ -184,7 +214,7 @@ class PagarmeRecipientService
             method:         'PATCH',
             path:           "/recipients/{$provider->recipient_id}/default-bank-account",
             payload:        $payload,
-            idempotencyKey: $this->idempotencyKey($provider, 'default-bank-account-'.sha1(json_encode($payload->toArray()))),
+            idempotencyKey: $this->idempotencyKey($provider, $this->bankAccountIdempotencySuffix($bankAccount)),
             errorMessage:   'Erro ao atualizar conta bancaria do recebedor no Pagar.me.',
         );
 
@@ -195,6 +225,25 @@ class PagarmeRecipientService
         return $provider->fresh();
     }
 
+    /**
+     * A chave precisa ser unica por operacao, e nao pelo conteudo da conta.
+     *
+     * Com o hash do payload, alternar a conta principal A -> B -> A reenviava a mesma chave
+     * da primeira troca: o Pagar.me tratava como repeticao, devolvia a resposta antiga e
+     * nao aplicava a volta para A - o saque continuaria caindo em B.
+     *
+     * Por isso o discriminador e um contador persistido, e nao o timestamp da conta:
+     * updated_at volta do banco truncado em segundos e repetiria a chave.
+     */
+    private function bankAccountIdempotencySuffix(ProviderBankAccount $bankAccount): string
+    {
+        return sprintf(
+            'default-bank-account-%d-%d',
+            $bankAccount->id,
+            $bankAccount->gateway_sync_count,
+        );
+    }
+
     //
 
     private function buildRecipientBankAccount(array $data): BankAccountData

+ 45 - 5
app/Services/PaymentService.php

@@ -3,10 +3,12 @@
 namespace App\Services;
 
 use App\Enums\ServicePackageStatusEnum;
+use App\Enums\NotificationTypeEnum;
 use App\Enums\PaymentSplitStatusEnum;
 use App\Enums\PaymentStatusEnum;
 use App\Exceptions\PaymentFailedException;
 use App\Exceptions\PaymentException;
+use App\Jobs\StartScheduleJob;
 use App\Models\ServicePackage;
 use App\Models\ClientPaymentMethod;
 use App\Models\Payment;
@@ -106,7 +108,7 @@ class PaymentService
                 throw new AuthorizationException;
             }
 
-            $schedules = $servicePackage->items->pluck('schedule')->filter()->values();
+            $schedules = $this->activePackageSchedules($servicePackage);
 
             $this->validateServicePackageForPayment($servicePackage, $schedules);
 
@@ -324,7 +326,7 @@ class PaymentService
                 throw new AuthorizationException;
             }
 
-            $schedules = $servicePackage->items->pluck('schedule')->filter()->values();
+            $schedules = $this->activePackageSchedules($servicePackage);
 
             $this->validateServicePackageForPayment($servicePackage, $schedules);
 
@@ -459,25 +461,35 @@ class PaymentService
         }
 
         if ($payment->service_package_id) {
-            DB::transaction(function () use ($payment): void {
+            $paidSchedules = DB::transaction(function () use ($payment): SupportCollection {
                 $servicePackage = ServicePackage::query()->lockForUpdate()->with('items')->find($payment->service_package_id);
 
                 if (! $servicePackage) {
-                    return;
+                    return collect();
                 }
 
                 $scheduleIds = $servicePackage->items->pluck('schedule_id')->filter()->unique();
 
-                Schedule::query()
+                $schedulesToPay = Schedule::query()
+                    ->with('provider')
                     ->whereIn('id', $scheduleIds)
                     ->where('status', 'accepted')
+                    ->get();
+
+                Schedule::query()
+                    ->whereIn('id', $schedulesToPay->pluck('id'))
+                    ->where('status', 'accepted')
                     ->update(['status' => 'paid']);
 
                 if ($servicePackage->status !== ServicePackageStatusEnum::PAID) {
                     $servicePackage->update(['status' => ServicePackageStatusEnum::PAID->value]);
                 }
+
+                return $schedulesToPay->toBase();
             });
 
+            $paidSchedules->each(fn (Schedule $schedule) => $this->notifyProviderAndScheduleStart($schedule));
+
             return;
         }
 
@@ -488,6 +500,25 @@ class PaymentService
         }
     }
 
+    private function notifyProviderAndScheduleStart(Schedule $schedule): void
+    {
+        if ($schedule->provider_id && $schedule->provider) {
+            app(NotificationService::class)->create([
+                'title'       => __('notifications.payment_confirmed_title'),
+                'description' => __('notifications.payment_confirmed_description'),
+                'origin'      => 'schedule',
+                'origin_id'   => $schedule->id,
+                'type'        => NotificationTypeEnum::SCHEDULE_PROVIDER_START->value,
+                'user_id'     => $schedule->provider->user_id,
+            ]);
+        }
+
+        $dateCleaned = Carbon::parse($schedule->date)->format('Y-m-d');
+
+        StartScheduleJob::dispatch($schedule->id)
+            ->delay(Carbon::parse($dateCleaned . ' ' . $schedule->start_time)->subHour());
+    }
+
     private function syncServicePackagesForSchedule(Schedule $schedule): void
     {
         ServicePackage::query()
@@ -525,6 +556,15 @@ class PaymentService
         }
     }
 
+    private function activePackageSchedules(ServicePackage $servicePackage): SupportCollection
+    {
+        return $servicePackage->items
+            ->pluck('schedule')
+            ->filter()
+            ->reject(fn (Schedule $schedule) => in_array($schedule->status, ['cancelled', 'rejected'], true))
+            ->values();
+    }
+
     private function validateServicePackageForPayment(ServicePackage $servicePackage, SupportCollection $schedules): void
     {
         if (! in_array($servicePackage->status, [ServicePackageStatusEnum::OPEN, ServicePackageStatusEnum::PAID], true)) {

+ 374 - 0
app/Services/ProviderBankAccountService.php

@@ -0,0 +1,374 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\ProviderWithdrawalStatusEnum;
+use App\Exceptions\BankAccountException;
+use App\Models\Bank;
+use App\Models\Provider;
+use App\Models\ProviderBankAccount;
+use App\Models\ProviderWithdrawal;
+use App\Services\Pagarme\PagarmeRecipientService;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class ProviderBankAccountService
+{
+    public function __construct(
+        private readonly PagarmeRecipientService $pagarmeRecipientService,
+    ) {}
+
+    public function getAll(Provider $provider): Collection
+    {
+        return $provider->bankAccounts()->with('bank')->get();
+    }
+
+    public function findForProvider(Provider $provider, int $id): ProviderBankAccount
+    {
+        $account = ProviderBankAccount::query()
+            ->where('provider_id', $provider->id)
+            ->with('bank')
+            ->find($id);
+
+        if (! $account) {
+            throw new BankAccountException('bank_account_not_found', 404);
+        }
+
+        return $account;
+    }
+
+    //
+
+    public function create(Provider $provider, array $data): ProviderBankAccount
+    {
+        $attributes = $this->buildAttributes($provider, $data);
+
+        $this->guardAgainstDuplicate($provider, $attributes);
+
+        $isFirstAccount = ! $provider->bankAccounts()->exists();
+
+        return DB::transaction(function () use ($provider, $attributes, $isFirstAccount) {
+            $account = ProviderBankAccount::create([
+                ...$attributes,
+                'provider_id' => $provider->id,
+                'is_primary'  => $isFirstAccount,
+                'is_active'   => true,
+            ]);
+
+            if ($account->is_primary) {
+                $this->syncPrimaryToGateway($provider, $account);
+            }
+
+            return $account->fresh('bank');
+        });
+    }
+
+    /**
+     * Cadastro do prestador: a conta enviada no formulario nasce como principal.
+     *
+     * Aqui o recebedor e criado com os dados de endereco que vieram na propria requisicao
+     * (no cadastro o endereco pode ainda nao estar persistido).
+     */
+    public function createPrimaryFromRecipientPayload(Provider $provider, array $data): ?ProviderBankAccount
+    {
+        $bankAccount = data_get($data, 'recipient_default_bank_account', []);
+
+        $hasBankData = ! empty(data_get($bankAccount, 'bank')) && ! empty(data_get($bankAccount, 'account_number'));
+
+        if (! $hasBankData || $provider->bankAccounts()->exists()) {
+            return null;
+        }
+
+        $attributes = $this->buildAttributes($provider, [
+            'bank_code'           => data_get($bankAccount, 'bank'),
+            'type'                => data_get($bankAccount, 'type', 'checking'),
+            'branch_number'       => data_get($bankAccount, 'branch_number'),
+            'branch_check_digit'  => data_get($bankAccount, 'branch_check_digit'),
+            'account_number'      => data_get($bankAccount, 'account_number'),
+            'account_check_digit' => data_get($bankAccount, 'account_check_digit'),
+        ]);
+
+        $account = ProviderBankAccount::create([
+            ...$attributes,
+            'provider_id' => $provider->id,
+            'is_primary'  => true,
+            'is_active'   => true,
+        ]);
+
+        $account->markGatewaySyncAttempt();
+
+        if (empty($provider->recipient_id)) {
+            $this->pagarmeRecipientService->createRecipientForProvider($provider, [
+                ...$data,
+                'recipient_default_bank_account' => $account->toGatewayBankAccount(),
+            ]);
+        } else {
+            $this->pagarmeRecipientService->updateDefaultBankAccount($provider, $account);
+        }
+
+        $account->forceFill([
+            'gateway_synced_at' => now(),
+            'gateway_sync_hash' => $account->gatewayHash(),
+        ])->save();
+
+        return $account;
+    }
+
+    public function update(Provider $provider, int $id, array $data): ProviderBankAccount
+    {
+        $account = $this->findForProvider($provider, $id);
+
+        $attributes = $this->buildAttributes($provider, $data, $account);
+
+        $this->guardAgainstDuplicate($provider, $attributes, $account->id);
+
+        $makePrimary = ! $account->is_primary && filter_var(data_get($data, 'is_primary', false), FILTER_VALIDATE_BOOLEAN);
+
+        if ($makePrimary) {
+            if (! $account->is_active) {
+                throw new BankAccountException('bank_account_inactive');
+            }
+
+            $this->guardAgainstWithdrawalInProgress($provider);
+        }
+
+        return DB::transaction(function () use ($provider, $account, $attributes, $makePrimary) {
+            $account->fill($attributes)->save();
+
+            if ($account->is_primary) {
+                $this->syncPrimaryToGateway($provider, $account);
+            } elseif ($makePrimary) {
+                $this->promoteToPrimary($provider, $account);
+            }
+
+            return $account->fresh('bank');
+        });
+    }
+
+    public function delete(Provider $provider, int $id): bool
+    {
+        $account = $this->findForProvider($provider, $id);
+
+        $this->guardAgainstWithdrawalInProgress($provider);
+
+        if ($provider->bankAccounts()->count() <= 1) {
+            throw new BankAccountException('bank_account_last_cannot_be_deleted');
+        }
+
+        if ($account->is_primary) {
+            throw new BankAccountException('bank_account_primary_requires_replacement');
+        }
+
+        return (bool) $account->delete();
+    }
+
+    //
+
+    /**
+     * Confere, direto no Pagar.me, se a conta padrao do recebedor e mesmo a conta
+     * principal daqui - e reenvia se estiver divergente.
+     *
+     * A transacao do banco garante que nada seja gravado localmente sem o gateway ter
+     * aceitado, mas nao cobre o caminho inverso: o PATCH ser aplicado e o commit local
+     * falhar depois. Como quem decide o destino da transferencia e o gateway, essa
+     * conferencia roda antes de cada saque, que e o unico momento em que isso importa.
+     */
+    public function ensureGatewayMatchesPrimary(Provider $provider): ProviderBankAccount
+    {
+        $account = $provider->primaryBankAccount()->first();
+
+        if (! $account || ! $account->is_active) {
+            throw new BankAccountException('provider_requires_synced_bank_account');
+        }
+
+        try {
+            $remote = data_get(
+                $this->pagarmeRecipientService->getRecipient($provider->recipient_id),
+                'default_bank_account',
+                [],
+            );
+
+            if (! $this->matchesGatewayAccount($account, $remote)) {
+                DB::transaction(fn () => $this->syncPrimaryToGateway($provider, $account));
+            }
+        } catch (\Throwable $e) {
+            Log::error('Falha ao conferir a conta bancaria do recebedor no Pagar.me', [
+                'provider_id'             => $provider->id,
+                'provider_bank_account_id'=> $account->id,
+                'exception'               => $e->getMessage(),
+            ]);
+
+            throw new BankAccountException('provider_requires_synced_bank_account');
+        }
+
+        return $account->fresh();
+    }
+
+    private function matchesGatewayAccount(ProviderBankAccount $account, array $remote): bool
+    {
+        $local = $account->toGatewayBankAccount();
+
+        foreach (['bank', 'branch_number', 'account_number', 'account_check_digit', 'type'] as $field) {
+            if (ltrim((string) data_get($local, $field), '0') !== ltrim((string) data_get($remote, $field), '0')) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    //
+
+    /**
+     * O Pagar.me tem uma conta so por recebedor: promover uma conta significa
+     * desmarcar a anterior e enviar a nova como conta padrao das transferencias.
+     */
+    private function promoteToPrimary(Provider $provider, ProviderBankAccount $account): void
+    {
+        ProviderBankAccount::query()
+            ->where('provider_id', $provider->id)
+            ->where('id', '!=', $account->id)
+            ->where('is_primary', true)
+            ->update(['is_primary' => false]);
+
+        $account->forceFill(['is_primary' => true])->save();
+
+        $this->syncPrimaryToGateway($provider, $account);
+    }
+
+    /**
+     * Envia a conta principal ao Pagar.me. O recebedor continua sendo um so por prestador:
+     * o que muda e a conta padrao que recebe as transferencias (saques).
+     */
+    private function syncPrimaryToGateway(Provider $provider, ProviderBankAccount $account): void
+    {
+        $account->markGatewaySyncAttempt();
+
+        if (empty($provider->recipient_id)) {
+            $this->pagarmeRecipientService->createRecipientForProvider(
+                $provider,
+                $this->buildRecipientDataFromProvider($provider, $account),
+            );
+        } else {
+            $this->pagarmeRecipientService->updateDefaultBankAccount($provider, $account);
+        }
+
+        $account->forceFill([
+            'gateway_synced_at' => now(),
+            'gateway_sync_hash' => $account->gatewayHash(),
+        ])->save();
+    }
+
+    private function buildRecipientDataFromProvider(Provider $provider, ProviderBankAccount $account): array
+    {
+        $provider->loadMissing(['user', 'addresses.city', 'addresses.state']);
+
+        $address = $provider->addresses->first();
+
+        return [
+            'recipient_name'                 => $provider->user?->name,
+            'recipient_email'                => $provider->user?->email,
+            'recipient_document'             => $provider->document,
+            'recipient_type'                 => 'individual',
+            'recipient_payment_mode'         => 'bank_transfer',
+            'recipient_metadata'             => [],
+            'recipient_default_bank_account' => $account->toGatewayBankAccount(),
+            'birth_date'                     => $provider->birth_date,
+            'phone'                          => $provider->user?->phone,
+            'address'                        => $address?->address,
+            'number'                         => $address?->number,
+            'district'                       => $address?->district,
+            'complement'                     => $address?->complement,
+            'city'                           => $address?->city?->name,
+            'state'                          => $address?->state?->code,
+            'zip_code'                       => $address?->zip_code,
+        ];
+    }
+
+    //
+
+    /**
+     * O titular e sempre o proprio prestador: o Pagar.me exige que o documento da conta
+     * seja o mesmo documento do recebedor, entao nada disso vem do cliente.
+     */
+    private function buildAttributes(Provider $provider, array $data, ?ProviderBankAccount $current = null): array
+    {
+        $bank = $this->resolveBank($data, $current);
+
+        $provider->loadMissing('user');
+
+        return [
+            'bank_id'             => $bank->id,
+            'bank_code'           => $bank->code,
+            'bank_name'           => $bank->name,
+            'holder_name'         => trim((string) $provider->user?->name),
+            'holder_document'     => $this->digits($provider->document),
+            'holder_type'         => 'individual',
+            'type'                => data_get($data, 'type', $current?->type),
+            'branch_number'       => $this->digits(data_get($data, 'branch_number', $current?->branch_number)),
+            'branch_check_digit'  => $this->digits(data_get($data, 'branch_check_digit', $current?->branch_check_digit)) ?: null,
+            'account_number'      => $this->digits(data_get($data, 'account_number', $current?->account_number)),
+            'account_check_digit' => $this->digits(data_get($data, 'account_check_digit', $current?->account_check_digit)),
+        ];
+    }
+
+    private function resolveBank(array $data, ?ProviderBankAccount $current = null): Bank
+    {
+        $bankId   = data_get($data, 'bank_id');
+        $bankCode = data_get($data, 'bank_code');
+
+        if (! $bankId && ! $bankCode) {
+            $bankId   = $current?->bank_id;
+            $bankCode = $current?->bank_code;
+        }
+
+        $bank = Bank::query()
+            ->where('active', true)
+            ->when($bankId, fn ($query) => $query->where('id', $bankId))
+            ->when(! $bankId && $bankCode, fn ($query) => $query->where('code', str_pad((string) $bankCode, 3, '0', STR_PAD_LEFT)))
+            ->first();
+
+        if (! $bank) {
+            throw new BankAccountException('bank_not_found');
+        }
+
+        return $bank;
+    }
+
+    private function guardAgainstDuplicate(Provider $provider, array $attributes, ?int $ignoreId = null): void
+    {
+        $exists = ProviderBankAccount::query()
+            ->where('provider_id', $provider->id)
+            ->where('bank_code', data_get($attributes, 'bank_code'))
+            ->where('branch_number', data_get($attributes, 'branch_number'))
+            ->where('account_number', data_get($attributes, 'account_number'))
+            ->where('account_check_digit', data_get($attributes, 'account_check_digit'))
+            ->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
+            ->exists();
+
+        if ($exists) {
+            throw new BankAccountException('bank_account_already_registered');
+        }
+    }
+
+    private function guardAgainstWithdrawalInProgress(Provider $provider): void
+    {
+        $inProgress = ProviderWithdrawal::query()
+            ->where('provider_id', $provider->id)
+            ->whereIn('status', [
+                ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
+                ProviderWithdrawalStatusEnum::PROCESSING,
+            ])
+            ->exists();
+
+        if ($inProgress) {
+            throw new BankAccountException('bank_account_blocked_by_withdrawal');
+        }
+    }
+
+    private function digits(?string $value): string
+    {
+        return preg_replace('/\D+/', '', (string) $value) ?? '';
+    }
+}

+ 71 - 83
app/Services/ProviderService.php

@@ -6,12 +6,14 @@ use App\Enums\ApprovalStatusEnum;
 use App\Enums\UserTypeEnum;
 use App\Models\Address;
 use App\Models\City;
+use App\Models\DeviceToken;
 use App\Models\Provider;
 use App\Models\ProviderServicesType;
 use App\Models\ProviderWorkingDay;
+use App\Models\PushNotificationLog;
 use App\Models\State;
 use App\Models\User;
-use App\Services\Pagarme\PagarmeRecipientService;
+use App\Notifications\Push\Prestador\Transacional\CadastroAprovadoPush;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Pagination\LengthAwarePaginator;
@@ -22,7 +24,7 @@ class ProviderService
 {
     public function __construct(
         private readonly AuthService $authService,
-        private readonly PagarmeRecipientService $pagarmeRecipientService,
+        private readonly ProviderBankAccountService $providerBankAccountService,
         private readonly MediaService $mediaService,
         private readonly EmailService $emailService,
     ) {}
@@ -49,8 +51,8 @@ class ProviderService
         return DB::transaction(function () use ($data) {
             $provider = Provider::create($data);
 
-            if (! empty(data_get($data, 'recipient_name')) && ! empty(data_get($data, 'recipient_default_bank_account'))) {
-                $this->pagarmeRecipientService->createRecipientForProvider($provider, $data);
+            if (! empty(data_get($data, 'recipient_name'))) {
+                $this->providerBankAccountService->createPrimaryFromRecipientPayload($provider, $data);
             }
 
             return $provider->fresh(['user', 'profileMedia']);
@@ -86,7 +88,7 @@ class ProviderService
         $provider = $model->fresh(['user', 'profileMedia']);
 
         if (! $wasAccepted && $provider->approval_status === ApprovalStatusEnum::ACCEPTED) {
-            $this->sendApprovedEmail($provider);
+            $this->notifyApproved($provider);
         }
 
         return $provider;
@@ -105,29 +107,6 @@ class ProviderService
 
     //
 
-    public function getPaymentMethods(int $id): array
-    {
-        $provider = Provider::find($id);
-
-        if (! $provider || empty($provider->recipient_default_bank_account)) {
-            return [];
-        }
-
-        $bankAccount = $provider->recipient_default_bank_account;
-
-        return [[
-            'id'                              => $provider->id,
-            'provider_id'                     => $provider->id,
-            'account_type'                    => 'bank_account',
-            'pix_key'                         => data_get($bankAccount, 'pix_key'),
-            'bank_account_type'               => data_get($bankAccount, 'type'),
-            'agency'                          => data_get($bankAccount, 'branch_number'),
-            'account'                         => data_get($bankAccount, 'account_number'),
-            'digit'                           => data_get($bankAccount, 'account_check_digit'),
-            'recipient_default_bank_account'  => $bankAccount,
-        ]];
-    }
-
     public function getPending(int $page = 1, int $perPage = 10): LengthAwarePaginator
     {
         return Provider::query()
@@ -239,18 +218,14 @@ class ProviderService
 
             $provider->save();
 
-            $bankAccount = data_get($data, 'recipient_default_bank_account', []);
-
-            $hasBankData = ! empty(data_get($bankAccount, 'bank')) && ! empty(data_get($bankAccount, 'account_number'));
-
-            if (! empty(data_get($data, 'recipient_name')) && $hasBankData && empty($provider->recipient_id)) {
-                $this->pagarmeRecipientService->createRecipientForProvider($provider, $data);
-            }
-
             Address::where('source', 'provider')->where('source_id', $provider->id)->delete();
 
             $this->createProviderAddress($provider->id, $data);
 
+            if (! empty(data_get($data, 'recipient_name'))) {
+                $this->providerBankAccountService->createPrimaryFromRecipientPayload($provider, $data);
+            }
+
             ProviderServicesType::where('provider_id', $provider->id)->delete();
 
             $this->createProviderServicesTypes($provider->id, $data);
@@ -286,30 +261,6 @@ class ProviderService
         }
     }
 
-    public function updateBankAccount(int $id, array $bankAccountData): ?Provider
-    {
-        $provider = Provider::with(['user', 'addresses.city', 'addresses.state'])->find($id);
-
-        if (! $provider) {
-            return null;
-        }
-
-        $bankAccountData['holder_name']     = data_get($bankAccountData, 'holder_name', $provider->user?->name);
-        $bankAccountData['holder_document'] = data_get($bankAccountData, 'holder_document', $provider->document);
-        $bankAccountData['holder_type']     = data_get($bankAccountData, 'holder_type', 'individual');
-
-        if (empty($provider->recipient_id)) {
-            $this->pagarmeRecipientService->createRecipientForProvider(
-                $provider,
-                $this->buildRecipientDataFromProvider($provider, $bankAccountData),
-            );
-        } else {
-            $this->pagarmeRecipientService->updateDefaultBankAccount($provider, $bankAccountData);
-        }
-
-        return $provider->fresh(['user', 'profileMedia']);
-    }
-
     //
 
     public function approve(int $id): Provider
@@ -328,7 +279,7 @@ class ProviderService
         });
 
         if (! $wasAccepted) {
-            $this->sendApprovedEmail($provider);
+            $this->notifyApproved($provider);
         }
 
         return $provider;
@@ -347,28 +298,6 @@ class ProviderService
 
     //
 
-    private function buildRecipientDataFromProvider(Provider $provider, array $bankAccountData): array
-    {
-        $address = $provider->addresses->first();
-
-        return [
-            'recipient_name'                 => $provider->user?->name,
-            'recipient_email'                => $provider->user?->email,
-            'recipient_document'             => $provider->document,
-            'recipient_type'                 => 'individual',
-            'recipient_payment_mode'         => 'bank_transfer',
-            'recipient_metadata'             => [],
-            'recipient_default_bank_account' => $bankAccountData,
-            'birth_date'                     => $provider->birth_date,
-            'phone'                          => $provider->user?->phone,
-            'address'                        => $address?->address,
-            'number'                         => $address?->number,
-            'city'                           => $address?->city?->name,
-            'state'                          => $address?->state?->code,
-            'zip_code'                       => $address?->zip_code,
-        ];
-    }
-
     private function createProviderAddress(int $providerId, array $data): void
     {
         $state = null;
@@ -475,6 +404,16 @@ class ProviderService
         return $digits === '' ? null : $digits;
     }
 
+    /**
+     * Efeitos colaterais da aprovação do cadastro do prestador.
+     * Chamado apenas na transição para ACCEPTED, sempre fora da transação.
+     */
+    private function notifyApproved(Provider $provider): void
+    {
+        $this->sendApprovedEmail($provider);
+        $this->sendApprovedPush($provider);
+    }
+
     private function sendApprovedEmail(Provider $provider): void
     {
         if (! empty($provider->user?->email)) {
@@ -501,4 +440,53 @@ class ProviderService
             'user_id'     => $provider->user?->id,
         ]);
     }
+
+    private function sendApprovedPush(Provider $provider): void
+    {
+        $user = $provider->user;
+
+        if (! $user) {
+            Log::warning('Push de aprovação do prestador ignorado: prestador sem usuário', [
+                'provider_id' => $provider->id,
+            ]);
+
+            return;
+        }
+
+        $notification = new CadastroAprovadoPush;
+
+        $alreadySent = PushNotificationLog::query()
+            ->where('user_id', $user->id)
+            ->where('label', $notification->label())
+            ->exists();
+
+        if ($alreadySent) {
+            return;
+        }
+
+        $hasActiveToken = DeviceToken::query()
+            ->where('user_id', $user->id)
+            ->where('app_type', $notification->target()->value)
+            ->where('active', true)
+            ->exists();
+
+        if (! $hasActiveToken) {
+            Log::warning('Push de aprovação do prestador ignorado: usuário não possui device token ativo', [
+                'provider_id' => $provider->id,
+                'user_id'     => $user->id,
+            ]);
+
+            return;
+        }
+
+        try {
+            app(PushNotificationService::class)->sendToUser($user, $notification);
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de aprovação do prestador', [
+                'provider_id' => $provider->id,
+                'user_id'     => $user->id,
+                'error'       => $exception->getMessage(),
+            ]);
+        }
+    }
 }

+ 6 - 2
app/Services/ProviderWithdrawalService.php

@@ -17,6 +17,7 @@ class ProviderWithdrawalService
 {
     public function __construct(
         private readonly PagarmeTransferService $pagarmeTransfer,
+        private readonly ProviderBankAccountService $providerBankAccounts,
     ) {}
 
     public function handleTransferWebhook(array $data): void
@@ -110,7 +111,9 @@ class ProviderWithdrawalService
             throw new \RuntimeException(__('messages.provider_requires_pagarme_recipient'));
         }
 
-        return DB::transaction(function () use ($provider) {
+        $primaryAccount = $this->providerBankAccounts->ensureGatewayMatchesPrimary($provider);
+
+        return DB::transaction(function () use ($provider, $primaryAccount) {
             Provider::query()
                 ->where('id', $provider->id)
                 ->lockForUpdate()
@@ -146,7 +149,7 @@ class ProviderWithdrawalService
 
             $idempotencyKey = sprintf('withdrawal-%d-%s', $provider->id, now()->timestamp);
 
-            $bankAccount = $provider->recipient_default_bank_account;
+            $bankAccount = $primaryAccount->toGatewayBankAccount();
 
             $withdrawal = ProviderWithdrawal::create([
                 'provider_id'        => $provider->id,
@@ -160,6 +163,7 @@ class ProviderWithdrawalService
 
                 'metadata' => [
                     'provider_id'                  => $provider->id,
+                    'provider_bank_account_id'     => $primaryAccount->id,
                     'gross_amount'                 => $available,
                     'payment_transfer_fee_amount'  => $paymentTransferFeeAmount,
                     'net_amount'                   => $netAmount,

+ 42 - 0
app/Services/ScheduleService.php

@@ -3,11 +3,13 @@
 namespace App\Services;
 
 use App\Exceptions\ScheduleStatusTransitionException;
+use App\Enums\ServicePackageStatusEnum;
 use App\Enums\UserTypeEnum;
 use App\Enums\NotificationTypeEnum;
 use App\Jobs\StartScheduleJob;
 use App\Models\Provider;
 use App\Models\Schedule;
+use App\Models\ServicePackage;
 use App\Rules\ScheduleBusinessRules;
 use App\Services\NotificationService;
 use App\Services\PushNotificationService;
@@ -508,6 +510,8 @@ class ScheduleService
                 'cancelled_by' => $cancelled_by,
             ]);
 
+            $this->cascadeCancelServicePackages($schedule, $cancelText, $cancelled_by);
+
             DB::commit();
 
             return $schedule->fresh(['client.user', 'provider.user', 'address']);
@@ -524,6 +528,44 @@ class ScheduleService
         }
     }
 
+    private function cascadeCancelServicePackages(Schedule $schedule, string $cancelText, $cancelledBy): void
+    {
+        $packageIds = DB::table('service_package_items')
+            ->where('schedule_id', $schedule->id)
+            ->pluck('service_package_id');
+
+        if ($packageIds->isEmpty()) {
+            return;
+        }
+
+        $packages = ServicePackage::query()
+            ->with('items.schedule')
+            ->whereIn('id', $packageIds)
+            ->get();
+
+        foreach ($packages as $package) {
+            $siblingSchedules = $package->items->pluck('schedule')->filter();
+
+            $siblingSchedules
+                ->filter(fn (Schedule $sibling) => $sibling->id !== $schedule->id
+                    && in_array($sibling->status, ['pending', 'accepted', 'paid'], true))
+                ->each(fn (Schedule $sibling) => $sibling->update([
+                    'status'       => 'cancelled',
+                    'cancel_text'  => $cancelText,
+                    'cancelled_by' => $cancelledBy,
+                ]));
+
+            $hasRealizedSchedule = $siblingSchedules->contains(
+                fn (Schedule $sibling) => in_array($sibling->status, ['started', 'finished'], true),
+            );
+
+            if ($package->status === ServicePackageStatusEnum::OPEN
+                || ($package->status === ServicePackageStatusEnum::PAID && ! $hasRealizedSchedule)) {
+                $package->update(['status' => ServicePackageStatusEnum::CANCELLED->value]);
+            }
+        }
+    }
+
     //
 
     private function calculateAmount(Provider $provider, string $periodType): float

+ 51 - 0
app/Services/SearchService.php

@@ -6,6 +6,7 @@ use App\Enums\GenderEnum;
 use App\Models\Address;
 use App\Models\Client;
 use App\Models\Provider;
+use App\Models\ProviderServicesType;
 use App\Rules\ScheduleBusinessRules;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
@@ -189,6 +190,56 @@ class SearchService
         })->values()->toArray();
     }
 
+    public function perfilPrestador(int $providerId): array
+    {
+        $provider = Provider::query()
+            ->visibleToCustomers()
+            ->whereNull('providers.deleted_at')
+            ->with(['user:id,name', 'profileMedia'])
+            ->findOrFail($providerId);
+
+        $address = Address::where('source', 'provider')
+            ->where('source_id', $provider->id)
+            ->orderByDesc('is_primary')
+            ->orderByDesc('id')
+            ->first();
+
+        $serviceTypes = ProviderServicesType::query()
+            ->join('service_types', 'service_types.id', '=', 'provider_services_types.service_type_id')
+            ->where('provider_services_types.provider_id', $provider->id)
+            ->whereNull('provider_services_types.deleted_at')
+            ->whereNull('service_types.deleted_at')
+            ->where('service_types.is_active', true)
+            ->orderBy('service_types.description')
+            ->get([
+                'service_types.id',
+                'service_types.description',
+            ])
+            ->toArray();
+
+        $totalReviews = DB::table('reviews')
+            ->leftJoin('schedules', 'schedules.id', '=', 'reviews.schedule_id')
+            ->where('reviews.origin', 'provider')
+            ->where('schedules.provider_id', $provider->id)
+            ->count();
+
+        return [
+            'provider_id'       => $provider->id,
+            'provider_name'     => $provider->user?->name,
+            'district'          => $address?->district,
+            'age'               => $provider->birth_date?->age,
+            'gender'            => $provider->gender,
+            'gender_label'      => GenderEnum::labelFor($provider->gender),
+            'average_rating'    => $provider->average_rating,
+            'total_services'    => $provider->total_services,
+            'total_reviews'     => $totalReviews,
+            'profile_media_url' => $provider->profileMedia?->path
+                ? Storage::temporaryUrl($provider->profileMedia->path, now()->addMinutes(60))
+                : null,
+            'service_types'     => $serviceTypes,
+        ];
+    }
+
     //
 
     private function applyCreditCardFee(?float $price): ?float

+ 1 - 1
app/Services/UserService.php

@@ -82,7 +82,7 @@ class UserService
 
     public function me(): ?User
     {
-        return User::with(['provider.profileMedia', 'client.profileMedia'])->find(Auth::id());
+        return User::with(['provider.profileMedia', 'provider.primaryBankAccount', 'client.profileMedia'])->find(Auth::id());
     }
 
     public function updateMe(array $data): User

+ 1 - 1
config/services.php

@@ -37,7 +37,7 @@ return [
 
     'gemini' => [
         'api_key' => env('GEMINI_API_KEY'),
-        'model'   => env('GEMINI_MODEL'),
+        'model'   => env('GEMINI_MODEL') ?: 'gemini-2.5-flash',
     ],
 
     'google_geocoding' => [

+ 0 - 5
config/withdrawals.php

@@ -1,5 +0,0 @@
-<?php
-
-return [
-    'pix_enabled' => (bool) env('PIX_WITHDRAWALS_ENABLED', false),
-];

+ 128 - 0
database/migrations/2026_08_12_100000_create_provider_bank_accounts_table.php

@@ -0,0 +1,128 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('provider_bank_accounts', function (Blueprint $table) {
+            $table->id();
+
+            $table->foreignId('provider_id')->constrained('providers')->cascadeOnDelete();
+            $table->foreignId('bank_id')->nullable()->constrained('banks')->nullOnDelete();
+
+            $table->string('bank_code', 3);
+            $table->string('bank_name')->nullable();
+
+            $table->string('holder_name');
+            $table->string('holder_document', 20);
+            $table->string('holder_type', 20)->default('individual');
+
+            $table->string('type', 20);
+            $table->string('branch_number', 4);
+            $table->string('branch_check_digit', 2)->nullable();
+            $table->string('account_number', 13);
+            $table->string('account_check_digit', 2);
+
+            $table->boolean('is_primary')->default(false);
+            $table->boolean('is_active')->default(true);
+
+            $table->timestamp('gateway_synced_at')->nullable();
+            $table->string('gateway_sync_hash')->nullable();
+            $table->unsignedInteger('gateway_sync_count')->default(0);
+            $table->json('gateway_payload')->nullable();
+
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index('provider_id');
+        });
+
+        DB::statement('
+            CREATE UNIQUE INDEX provider_bank_accounts_primary_unique
+            ON provider_bank_accounts (provider_id)
+            WHERE is_primary = true AND deleted_at IS NULL
+        ');
+
+        DB::statement('
+            CREATE UNIQUE INDEX provider_bank_accounts_account_unique
+            ON provider_bank_accounts (provider_id, bank_code, branch_number, account_number, account_check_digit)
+            WHERE deleted_at IS NULL
+        ');
+
+        $this->backfillFromProviders();
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('provider_bank_accounts');
+    }
+
+    /**
+     * Migra a conta unica atual (providers.recipient_default_bank_account) para a nova tabela,
+     * marcando ela como principal. A coluna json continua sendo o padrão do que esta no Pagar.me.
+     * Criado apenas para evitar que o pessoal do Diária precise cadastrar as contas novamente no momento do deploy.
+     */
+    private function backfillFromProviders(): void
+    {
+        $banks = DB::table('banks')->pluck('id', 'code');
+
+        DB::table('providers')
+            ->whereNotNull('recipient_default_bank_account')
+            ->orderBy('id')
+            ->chunkById(100, function ($providers) use ($banks) {
+                foreach ($providers as $provider) {
+                    $account = json_decode($provider->recipient_default_bank_account ?? '', true);
+
+                    if (! is_array($account) || empty(data_get($account, 'bank')) || empty(data_get($account, 'account_number'))) {
+                        continue;
+                    }
+
+                    $bankCode = str_pad((string) data_get($account, 'bank'), 3, '0', STR_PAD_LEFT);
+
+                    $attributes = [
+                        'holder_name'         => data_get($account, 'holder_name') ?: '',
+                        'holder_document'     => data_get($account, 'holder_document') ?: '',
+                        'holder_type'         => data_get($account, 'holder_type') ?: 'individual',
+                        'type'                => data_get($account, 'type') ?: 'checking',
+                        'branch_number'       => data_get($account, 'branch_number') ?: '',
+                        'branch_check_digit'  => data_get($account, 'branch_check_digit'),
+                        'account_number'      => data_get($account, 'account_number'),
+                        'account_check_digit' => data_get($account, 'account_check_digit') ?: '',
+                    ];
+
+                    $gatewayHash = sha1(json_encode([
+                        'holder_name'         => $attributes['holder_name'],
+                        'holder_type'         => $attributes['holder_type'],
+                        'holder_document'     => $attributes['holder_document'],
+                        'bank'                => $bankCode,
+                        'branch_number'       => $attributes['branch_number'],
+                        'branch_check_digit'  => $attributes['branch_check_digit'],
+                        'account_number'      => $attributes['account_number'],
+                        'account_check_digit' => $attributes['account_check_digit'],
+                        'type'                => $attributes['type'],
+                    ]));
+
+                    DB::table('provider_bank_accounts')->insert([
+                        'provider_id'         => $provider->id,
+                        'bank_id'             => $banks[$bankCode] ?? null,
+                        'bank_code'           => $bankCode,
+                        'bank_name'           => null,
+                        ...$attributes,
+                        'is_primary'          => true,
+                        'is_active'           => true,
+                        'gateway_synced_at'   => $provider->recipient_id ? now() : null,
+                        'gateway_sync_hash'   => $provider->recipient_id ? $gatewayHash : null,
+                        'gateway_sync_count'  => $provider->recipient_id ? 1 : 0,
+                        'gateway_payload'     => null,
+                        'created_at'          => now(),
+                        'updated_at'          => now(),
+                    ]);
+                }
+            });
+    }
+};

+ 11 - 0
lang/en/messages.php

@@ -25,6 +25,17 @@ return [
     'schedule_requires_confirmed_provider'         => 'The appointment requires a confirmed provider before payment can be created.',
     'schedule_amount_must_be_positive'             => 'The appointment amount must be greater than zero before payment can be created.',
     'provider_requires_pagarme_recipient'          => 'The provider must have a Pagar.me recipient_id to receive the split.',
+    'bank_account_not_found'                       => 'Bank account not found.',
+    'bank_not_found'                               => 'Bank not found or inactive.',
+    'bank_account_already_registered'              => 'This bank account is already registered.',
+    'bank_account_inactive'                        => 'An inactive bank account cannot be used.',
+    'bank_account_primary_requires_replacement'    => 'Set another account as primary before deleting this one.',
+    'bank_account_last_cannot_be_deleted'          => 'You must keep at least one bank account registered.',
+    'bank_account_blocked_by_withdrawal'           => 'You have a withdrawal in progress. Wait until it completes to change your bank accounts.',
+    'bank_account_created'                         => 'Bank account registered successfully!',
+    'bank_account_updated'                         => 'Bank account updated successfully!',
+    'bank_account_deleted'                         => 'Bank account deleted successfully!',
+    'provider_requires_synced_bank_account'        => 'Your primary bank account is not synced. Review your bank details before withdrawing.',
     'schedule_payment_already_in_progress'         => 'A payment is already in progress for this appointment.',
     'payment_card_required'                        => 'A payment card or card_id is required.',
     'payment_card_not_found_or_inactive'           => 'The payment card was not found or is inactive for this client.',

+ 11 - 0
lang/es/messages.php

@@ -25,6 +25,17 @@ return [
     'schedule_requires_confirmed_provider'         => 'El servicio programado necesita un prestador confirmado para generar el pago.',
     'schedule_amount_must_be_positive'             => 'El valor del servicio programado debe ser mayor que cero para generar el pago.',
     'provider_requires_pagarme_recipient'          => 'El prestador debe tener un recipient_id de Pagar.me para recibir el split.',
+    'bank_account_not_found'                       => 'Cuenta bancaria no encontrada.',
+    'bank_not_found'                               => 'Banco no encontrado o inactivo.',
+    'bank_account_already_registered'              => 'Esta cuenta bancaria ya está registrada.',
+    'bank_account_inactive'                        => 'No se puede usar una cuenta bancaria inactiva.',
+    'bank_account_primary_requires_replacement'    => 'Define otra cuenta como principal antes de eliminar esta.',
+    'bank_account_last_cannot_be_deleted'          => 'Debes mantener al menos una cuenta bancaria registrada.',
+    'bank_account_blocked_by_withdrawal'           => 'Tienes un retiro en curso. Espera a que finalice para cambiar tus cuentas bancarias.',
+    'bank_account_created'                         => '¡Cuenta bancaria registrada con éxito!',
+    'bank_account_updated'                         => '¡Cuenta bancaria actualizada con éxito!',
+    'bank_account_deleted'                         => '¡Cuenta bancaria eliminada con éxito!',
+    'provider_requires_synced_bank_account'        => 'Tu cuenta bancaria principal no está sincronizada. Revisa tus datos bancarios antes de retirar.',
     'schedule_payment_already_in_progress'         => 'Ya existe un pago en curso para este servicio programado.',
     'payment_card_required'                        => 'La tarjeta de pago o el card_id es obligatorio.',
     'payment_card_not_found_or_inactive'           => 'La tarjeta de pago no fue encontrada o está inactiva para este cliente.',

+ 11 - 0
lang/pt/messages.php

@@ -25,6 +25,17 @@ return [
     'schedule_requires_confirmed_provider'         => 'O agendamento precisa ter um prestador confirmado para gerar o pagamento.',
     'schedule_amount_must_be_positive'             => 'O agendamento precisa ter valor maior que zero para gerar o pagamento.',
     'provider_requires_pagarme_recipient'          => 'O prestador precisa ter recipient_id do Pagar.me para receber o split.',
+    'bank_account_not_found'                       => 'Conta bancária não encontrada.',
+    'bank_not_found'                               => 'Banco não encontrado ou inativo.',
+    'bank_account_already_registered'              => 'Esta conta bancária já está cadastrada.',
+    'bank_account_inactive'                        => 'Não é possível usar uma conta bancária inativa.',
+    'bank_account_primary_requires_replacement'    => 'Defina outra conta como principal antes de excluir esta.',
+    'bank_account_last_cannot_be_deleted'          => 'Você precisa manter ao menos uma conta bancária cadastrada.',
+    'bank_account_blocked_by_withdrawal'           => 'Você possui um saque em andamento. Aguarde a conclusão para alterar suas contas bancárias.',
+    'bank_account_created'                         => 'Conta bancária cadastrada com sucesso!',
+    'bank_account_updated'                         => 'Conta bancária atualizada com sucesso!',
+    'bank_account_deleted'                         => 'Conta bancária excluída com sucesso!',
+    'provider_requires_synced_bank_account'        => 'Sua conta bancária principal não está sincronizada. Revise seus dados bancários antes de sacar.',
     'schedule_payment_already_in_progress'         => 'Já existe um pagamento em andamento para este agendamento.',
     'payment_card_required'                        => 'O cartão de pagamento ou card_id é obrigatório.',
     'payment_card_not_found_or_inactive'           => 'Cartão de pagamento não encontrado ou inativo para este cliente.',

+ 4 - 2
phpunit.xml

@@ -22,8 +22,10 @@
         <env name="APP_MAINTENANCE_DRIVER" value="file"/>
         <env name="BCRYPT_ROUNDS" value="4"/>
         <env name="CACHE_STORE" value="array"/>
-        <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
-        <!-- <env name="DB_DATABASE" value=":memory:"/> -->
+        <env name="DB_CONNECTION" value="pgsql"/>
+        <env name="DB_DATABASE" value="diarista_test"/>
+        <env name="PAGARME_SECRET_KEY" value="sk_test_fake"/>
+        <env name="PAGARME_PLATFORM_RECIPIENT_ID" value="re_platform_fake"/>
         <env name="MAIL_MAILER" value="array"/>
         <env name="PULSE_ENABLED" value="false"/>
         <env name="QUEUE_CONNECTION" value="sync"/>

+ 156 - 54
resources/chatbot/context_cliente.txt

@@ -10,6 +10,19 @@ REGRAS ESTRITAS:
 
 >>> As restrições de segurança e escopo aplicadas a este assistente estão definidas no arquivo restricoes.md, carregado automaticamente pelo sistema antes deste contexto. As seções aplicáveis a este app são: "SEGURANÇA ABSOLUTA" e "RESTRIÇÕES DE ESCOPO — APP CLIENTE". <<<
 
+========================================================
+RESUMO RÁPIDO — COMO O APP FUNCIONA HOJE
+========================================================
+
+Este resumo vale mais do que qualquer suposição. Em caso de dúvida, siga o que está aqui.
+
+- Para contratar, o cliente monta um PEDIDO (também chamado de carrinho). Ele escolhe um profissional, adiciona um ou mais horários ao pedido e depois confirma o pedido.
+- UM PEDIDO É SEMPRE DE UM ÚNICO PROFISSIONAL. Não é possível ter horários de dois profissionais no mesmo pedido.
+- O profissional precisa aceitar o pedido antes do pagamento. Só depois do aceite o cliente paga.
+- O aplicativo aceita DOIS meios de pagamento: PIX e cartão de crédito. Não há boleto, débito, dinheiro ou parcelamento.
+- Não é obrigatório ter cartão cadastrado: dá para pagar tudo por PIX.
+- O pagamento é do pedido inteiro, nunca de um horário isolado dentro do pedido.
+
 ========================================================
 FUNCIONALIDADES DISPONÍVEIS PARA CLIENTES
 ========================================================
@@ -19,7 +32,8 @@ FUNCIONALIDADES DISPONÍVEIS PARA CLIENTES
 ------------------------------
 A tela inicial é o painel principal do cliente. Nela o cliente pode visualizar:
 - Um resumo com estatísticas (total de serviços, agendamentos realizados, etc.)
-- Agendamentos pendentes (aguardando aceite do prestador)
+- Pedidos aguardando confirmação do profissional
+- Pedidos já aceitos e aguardando pagamento
 - Agendamentos do dia atual
 - Próximos agendamentos confirmados
 - Últimos serviços concluídos
@@ -28,7 +42,9 @@ A tela inicial é o painel principal do cliente. Nela o cliente pode visualizar:
 
 O cliente NÃO consegue fazer agendamentos diretamente pela tela inicial — para contratar, ele deve acessar a tela de Busca.
 
-Caso o cadastro esteja incompleto ou não haja cartão de crédito cadastrado, o sistema exibe um aviso na tela inicial orientando o cliente a completar o perfil ou adicionar um método de pagamento antes de contratar.
+Caso o cadastro esteja incompleto ou não haja cartão de crédito cadastrado, a tela inicial exibe o aviso "Atualize seus dados de pagamento!" com o botão "Resolver agora", que abre a tela de Meios de Pagamento. Esse aviso NÃO impede o cliente de contratar: é possível agendar e pagar por PIX sem nenhum cartão cadastrado.
+
+Quando o profissional aceita um pedido que ainda não foi pago, a tela inicial abre automaticamente o aviso "Aceitou realizar sua diária!", com o resumo dos valores e o botão "ir para o pagamento". Esse aviso aparece uma única vez por pedido. Se o cliente fechar sem pagar, ele ainda pode pagar pelo card "Aguardando confirmação" na própria tela inicial.
 
 ------------------------------
 2. PERFIL
@@ -46,15 +62,21 @@ O cliente pode editar:
 
 Como editar: clicar em "Editar Perfil" → alterar os campos desejados → clicar em "Atualizar". O botão de salvar só fica ativo se houver alguma alteração.
 
-2.2 PAGAMENTOS
-Gerenciamento dos cartões de crédito cadastrados para pagamento dos serviços.
+2.2 PAGAMENTOS (MEUS CARTÕES)
+Onde o cliente gerencia os cartões de crédito salvos.
+
+Caminho: Perfil → "Pagamentos" (Meus cartões) → tela "Meios de Pagamento".
 
-- VISUALIZAR: o cliente vê a lista de cartões cadastrados com bandeira, nome do titular e últimos 4 dígitos.
-- ADICIONAR: clicar em "Adicionar Cartão" → preencher número do cartão, nome do titular, validade (MM/AAAA) e CVV → clicar em "Adicionar Cartão". O cartão é tokenizado de forma segura via Pagar.me.
-- EDITAR: clicar no ícone de lápis ao lado do cartão → alterar os dados disponíveis → clicar em "Salvar".
+- VISUALIZAR: lista os cartões cadastrados com bandeira, nome do titular, últimos 4 dígitos e validade.
+- ADICIONAR: clicar em "adicionar novo cartão" → preencher número do cartão, nome do titular (exatamente como está no cartão), validade no formato mm/aa e CVV (os 3 ou 4 dígitos do verso) → confirmar. Os dados do cartão são enviados de forma segura e o aplicativo não guarda o número completo.
+- EDITAR: clicar no ícone de lápis ao lado do cartão → alterar os dados → salvar.
 - REMOVER: clicar no ícone de lixeira ao lado do cartão → confirmar a exclusão.
 
-Observação: o cliente precisa ter pelo menos um cartão cadastrado para poder realizar agendamentos.
+O cliente pode manter mais de um cartão salvo e escolher qual usar no momento do pagamento.
+
+Sobre PIX: não existe nada a cadastrar. O PIX é escolhido na hora de pagar, direto na tela de Pagamento (ver seção 5).
+
+IMPORTANTE: não é necessário ter cartão cadastrado para agendar. É possível montar o pedido, enviá-lo e pagar por PIX.
 
 2.3 ENDEREÇOS
 Gerenciamento dos endereços utilizados nos serviços.
@@ -62,11 +84,13 @@ Gerenciamento dos endereços utilizados nos serviços.
 - VISUALIZAR: lista todos os endereços cadastrados com tipo (Residencial, Comercial ou Outro), logradouro, número, complemento, bairro, cidade e estado. O endereço principal é destacado com um ícone de confirmação (✓).
 - ADICIONAR: clicar em "Adicionar Endereço" → preencher CEP (o sistema preenche automaticamente rua, bairro, cidade e estado), número, complemento (opcional) e selecionar o tipo do endereço → salvar.
 - EDITAR: clicar no menu "..." ao lado do endereço → selecionar "Editar" → alterar os dados desejados → salvar.
-- MARCAR COMO PRINCIPAL: clicar no menu "..." ao lado do endereço → selecionar "Marcar como principal". Apenas um endereço pode ser o principal ao mesmo tempo. O endereço principal é o utilizado por padrão nos agendamentos.
+- MARCAR COMO PRINCIPAL: clicar no menu "..." ao lado do endereço → selecionar "Marcar como principal". Apenas um endereço pode ser o principal ao mesmo tempo.
 - EXCLUIR: clicar no ícone de lixeira ao lado de um endereço não principal → confirmar a exclusão. O endereço principal não pode ser excluído diretamente (é preciso primeiro marcar outro como principal).
 
 Tipos de endereço disponíveis: Residencial, Comercial, Outro.
 
+ATENÇÃO: o pedido é sempre enviado para o ENDEREÇO PRINCIPAL. Se o cliente não tiver um endereço principal definido, o app avisa "Cadastre um endereço principal para enviar a solicitação." e o pedido não pode ser confirmado. Para trocar o local do serviço, é preciso marcar o endereço desejado como principal antes de confirmar o pedido.
+
 2.4 FAVORITOS
 Lista de prestadores que o cliente salvou como favoritos.
 
@@ -75,18 +99,18 @@ Lista de prestadores que o cliente salvou como favoritos.
 - INDICAR: clicar no botão "Indicar" para compartilhar o prestador favorito com outras pessoas.
 - BUSCAR: caso não haja favoritos, o botão "Buscar Diaristas" redireciona para a tela de busca.
 
-Como adicionar favoritos: na tela de agendamento de um prestador, clicar no ícone de coração (♡). O prestador é adicionado à lista de favoritos.
+Como adicionar favoritos: na tela de agendamento de um prestador, clicar no ícone de coração (♡).
 
 2.5 PRIVACIDADE (BLOQUEIOS)
 Gerenciamento dos prestadores que o cliente bloqueou.
 
 - VISUALIZAR: lista os prestadores bloqueados com nome, bairro e avaliação.
-- DESBLOQUEAR: clicar em "Desbloquear" ao lado do prestador → o prestador é removido da lista de bloqueados e volta a aparecer nas buscas.
+- DESBLOQUEAR: clicar em "Desbloquear" ao lado do prestador → o prestador volta a aparecer nas buscas.
 
 Como bloquear um prestador: dentro do detalhe de um agendamento ou no perfil do prestador, há a opção de bloquear. Prestadores bloqueados não aparecem mais nas buscas e não podem ser contratados.
 
 2.6 AJUDA
-Acesso ao assistente virtual do aplicativo (este chatbot). O cliente pode enviar mensagens e receber orientações sobre as funcionalidades do app.
+Acesso ao assistente virtual do aplicativo (este chatbot). O cliente pode enviar mensagens e receber orientações sobre as funcionalidades do app. No topo desta tela há o botão de atendente humano.
 
 2.7 POLÍTICA DE PRIVACIDADE
 Abre a página de política de privacidade do aplicativo no navegador.
@@ -107,54 +131,120 @@ Acessada pelo menu inferior do aplicativo. Permite encontrar diaristas disponív
   - Ordenações disponíveis: preço (menor/maior), avaliação (maior/menor), número de avaliações, número de serviços realizados, mais recentes/mais antigos.
 - ALTERNAR PERÍODO DE PREÇO: usar as setas para selecionar a duração do serviço (2h, 4h, 6h ou 8h) e ver o preço correspondente de cada prestador.
 - VER PRESTADOR: cada card exibe nome, bairro, avaliação média, total de avaliações, total de serviços realizados, distância em km e preço pelo período selecionado.
-- CONTRATAR: clicar em "Agendar" no card do prestador para iniciar o fluxo de agendamento.
+- AGENDAR: clicar em "agendar" no card do prestador para iniciar o fluxo de agendamento.
 
-No topo da tela de busca há também um atalho para o Agendamento Sob Medida.
+No topo da tela de busca há também um atalho "sob medida", para o Agendamento Sob Medida.
 
-Importante: apenas prestadores com dias de trabalho cadastrados e que não estejam bloqueados pelo cliente (nem tenham bloqueado o cliente) aparecem na listagem.
+Importante: apenas prestadores com dias de trabalho cadastrados, com dados bancários cadastrados e que não estejam bloqueados pelo cliente (nem tenham bloqueado o cliente) aparecem na listagem.
 
 ------------------------------
-4. AGENDAMENTO PADRÃO (contratar um prestador específico)
+4. AGENDAMENTO PADRÃO (PEDIDOS / CARRINHO)
 ------------------------------
-Fluxo iniciado ao clicar em "Agendar" no card de um prestador na tela de busca.
+Este é o fluxo normal de contratação. O cliente monta um PEDIDO com um ou mais horários de um mesmo profissional e depois confirma o pedido inteiro.
+
+O pedido fica acessível a qualquer momento pelo item "Pedidos" no menu inferior (ícone de sacola), que mostra um contador com a quantidade de horários já adicionados.
+
+PASSO A PASSO COMPLETO:
+
+Passo 1 — ESCOLHER O PROFISSIONAL:
+- Na tela de Busca, clicar em "agendar" no card do profissional desejado.
+- Abre a tela "Agendamento", com os dados do profissional, as avaliações de outros clientes e o calendário.
+
+Passo 2 — ESCOLHER A DATA:
+- O calendário exibe apenas as datas disponíveis. Ficam bloqueadas: datas passadas, dias em que o profissional não trabalha, dias bloqueados por ele, horários já ocupados e as datas que o cliente já colocou no pedido.
+
+Passo 3 — ESCOLHER O TIPO DE SERVIÇO:
+- Aparece a lista de tipos de serviço com o preço de cada um. Os tipos são definidos pela duração:
+  - Integral: até 8h de serviço. Ideal para limpeza com demandas maiores e espaços mais amplos.
+  - Padrão: até 6h de serviço. Ideal para a rotina de limpeza tradicional.
+  - Meio período: até 4h de serviço. Ideal para espaços menores, estúdios ou escritórios.
+  - Diária Rápida: até 2h de serviço. Ideal para quartos de hotéis, pequenos ambientes ou serviços específicos.
+- Clicar no botão "carrinho" do tipo desejado.
+
+Passo 4 — ESCOLHER O HORÁRIO:
+- Selecionar a faixa de horário disponível naquele dia.
+- Informar se o serviço será "Com refeição" ou "Sem refeição".
+- Confirmar. O app avisa "Horário adicionado aos pedidos." e leva o cliente para a tela "Pedidos".
+
+Passo 5 — TELA "PEDIDOS":
+- Lista todos os horários já adicionados, cada um com um ícone de lixeira para remover.
+- Botão "adicionar serviço": volta para o agendamento e permite incluir mais horários do MESMO profissional.
+- Card "Resumo": mostra o profissional, o endereço, a quantidade de horários e o valor total.
+- Se houver desconto por quantidade, o app avisa nessa tela.
+
+Passo 6 — CONFIRMAR:
+- Clicar em "confirmar pedido(s)".
+- Mensagem de sucesso: "Pedidos fechados. Aguarde a aprovação dos profissionais."
+- Os agendamentos ficam com status Pendente até o profissional responder.
 
-Passo 1 — SELECIONAR DATA:
-- O calendário exibe apenas as datas disponíveis (dias em que o prestador trabalha, sem bloqueios e respeitando o limite de 2 agendamentos por semana com o mesmo prestador).
-- Datas no passado e datas indisponíveis ficam bloqueadas.
+Passo 7 — AGUARDAR O ACEITE:
+- O profissional aceita ou recusa o pedido INTEIRO — ele não escolhe algumas datas apenas.
+- Se ele recusar, todos os agendamentos daquele pedido são cancelados e o cliente pode montar um novo pedido, com ele ou com outro profissional.
 
-Passo 2 — SELECIONAR TIPO DE SERVIÇO:
-- Escolher o tipo de serviço desejado (ex.: limpeza padrão, limpeza pesada, etc.)
+Passo 8 — PAGAR:
+- Depois do aceite, o cliente paga o pedido. Ver seção 5.
 
-Passo 3 — SELECIONAR HORÁRIO:
-- Escolher o período (manhã ou tarde) e o horário de início e fim, considerando os bloqueios parciais do prestador naquele dia.
+REGRAS IMPORTANTES DO PEDIDO:
 
-Passo 4 — RESUMO DO PEDIDO:
-- Exibe os agendamentos adicionados com data, horário e tipo de serviço.
-- ADICIONAR MAIS DATAS: clicar em "Adicionar Data" para incluir outros dias no mesmo pedido (o calendário reaparece para nova seleção). Podem ser adicionadas várias datas a um único envio.
-- REMOVER DATA: clicar no ícone de "-" ao lado de uma data para removê-la do pedido.
-- ENVIAR PEDIDO: clicar em "Enviar" para finalizar. O pedido é enviado ao prestador que precisa aceitar antes de ser confirmado.
+- UM PEDIDO, UM PROFISSIONAL. Se o cliente tentar adicionar um horário de outro profissional a um pedido que já tem horários, o app exibe: "Seu pedido já possui horários com outro profissional. Finalize ou esvazie o pedido atual antes de agendar com outro profissional."
+  Para agendar com outro profissional, há dois caminhos:
+  a) Confirmar o pedido atual (botão "confirmar pedido(s)" na tela Pedidos) — depois disso o pedido é esvaziado e já dá para montar um novo com outro profissional; ou
+  b) Remover todos os horários do pedido atual, um a um, pelo ícone de lixeira.
+  Não existe botão de "esvaziar tudo" nem forma de manter dois pedidos abertos ao mesmo tempo.
 
-O pagamento é processado após o prestador aceitar o agendamento.
+- LIMITE SEMANAL: no máximo 2 agendamentos por semana (de domingo a sábado) com o mesmo profissional. Se o cliente tentar passar disso, o app avisa: "Limite de 2 agendamentos por semana com o mesmo profissional atingido." Para incluir mais horários no mesmo pedido, é preciso escolher datas de outras semanas.
 
-Regras de negócio do agendamento:
-- Máximo de 2 agendamentos por semana (de domingo a sábado) com o mesmo prestador.
-- O prestador precisa ter disponibilidade configurada para o dia e período selecionados.
-- Não é possível agendar em dias bloqueados pelo prestador.
-- Não é possível agendar se o prestador tiver outro agendamento no mesmo horário.
+- DESCONTO POR QUANTIDADE: pedidos com 3 ou mais horários recebem desconto no valor total. Enquanto o cliente não chega lá, o app mostra quantos horários faltam; quando o desconto passa a valer, o app avisa na tela de Pedidos e mostra o valor economizado. O desconto NÃO se aplica ao Agendamento Sob Medida.
 
-Na tela de agendamento também é possível:
-- VER AVALIAÇÕES do prestador: rolando a tela, é exibida uma lista horizontal com avaliações de outros clientes (estrelas, comentário e nome do avaliador).
+- O pagamento é sempre do pedido inteiro. Não é possível pagar apenas um horário de um pedido com vários.
+
+- Não existe agendamento recorrente automático: cada horário é adicionado individualmente ao pedido.
+
+- Depois de confirmado, não é possível alterar a data ou o horário de um agendamento. O caminho é cancelar e montar um novo pedido.
 
 ------------------------------
-5. AGENDAMENTO SOB MEDIDA
+5. PAGAMENTO
 ------------------------------
-Funcionalidade para o cliente publicar um pedido aberto de serviço, permitindo que prestadores disponíveis se candidatem. Acessada pelo atalho na tela de Busca ou pelo menu inferior.
+O pagamento acontece DEPOIS que o profissional aceita o pedido. Nada é cobrado, reservado ou pré-autorizado no momento em que o pedido é enviado.
+
+Como chegar na tela de Pagamento:
+- Pelo aviso "Aceitou realizar sua diária!", que abre sozinho na tela inicial → botão "ir para o pagamento"; ou
+- Pelo card "Aguardando confirmação" na tela inicial, que mostra "Realize o pagamento para {nome do profissional}".
+
+Na tela "Pagamento" o cliente vê o endereço do serviço, escolhe o meio de pagamento, confere o total, marca "Concordo com os termos e condições" (obrigatório) e clica em "confirmar pagamento".
+
+MEIOS DE PAGAMENTO DISPONÍVEIS — apenas estes dois:
+
+5.1 PIX
+- Não exige cadastro prévio.
+- Ao escolher PIX e confirmar, o app abre a tela "Pagamento com PIX" com o QR Code, o código copia-e-cola e um contador regressivo.
+- Como pagar: copiar o código pelo botão "copiar código pix", abrir o aplicativo do banco, escolher pagar com Pix, colar o código e finalizar. Também é possível ler o QR Code pelo app do banco.
+- O cliente também recebe as instruções por e-mail.
+- O código PIX expira em 30 minutos.
+- Se o código expirar, o agendamento NÃO é cancelado. Basta voltar à tela de pagamento e gerar um novo código.
+- A confirmação costuma chegar em alguns segundos; a própria tela atualiza sozinha quando o pagamento é identificado.
+
+5.2 CARTÃO DE CRÉDITO
+- É possível usar um cartão já salvo (basta selecioná-lo na lista) ou cadastrar um novo pela opção "Adicionar cartão", ali mesmo na tela de pagamento.
+- Após confirmar, o app mostra a tela de processamento e depois a confirmação "Diária agendada com sucesso!".
+- Não há parcelamento: a cobrança é feita em uma única vez.
+
+O que NÃO existe: boleto, cartão de débito, pagamento em dinheiro, transferência, carteira digital ou pagamento direto ao profissional. Combinar pagamento fora do aplicativo não é permitido.
+
+Depois da confirmação do pagamento, os agendamentos do pedido passam para o status Pago e o profissional é notificado.
+
+------------------------------
+6. AGENDAMENTO SOB MEDIDA
+------------------------------
+Funcionalidade para o cliente publicar um pedido aberto de serviço, permitindo que prestadores disponíveis se candidatem. Acessada pelo atalho "sob medida" na tela de Busca ou pelo menu inferior.
+
+Diferente do agendamento padrão, o Sob Medida NÃO usa o pedido/carrinho e NÃO recebe o desconto por quantidade.
 
 Como criar um Agendamento Sob Medida:
 
-1. QUANTIDADE: definir quantos serviços serão necessários (incrementar/decrementar com os botões + e -).
+1. QUANTIDADE: definir quantos serviços serão necessários (botões + e -).
 2. TIPO DE ENDEREÇO: selecionar Residencial ou Comercial.
-3. TIPO DE SERVIÇO: selecionar o tipo de serviço desejado (limpeza padrão, pesada, etc.).
+3. TIPO DE SERVIÇO: selecionar o tipo de serviço desejado.
 4. ESPECIALIDADES (opcional): marcar as especialidades preferidas (ex.: limpeza de vidros, organização, etc.).
 5. DESCRIÇÃO (opcional): campo de texto para detalhar o que é necessário.
 6. FAIXA DE PREÇO: usar o slider duplo para definir o valor mínimo e máximo aceitável (entre R$100 e R$500 por serviço de 8h — valores proporcionais para períodos menores).
@@ -164,32 +254,41 @@ Como criar um Agendamento Sob Medida:
 
 Após publicar, o pedido aparece na tela inicial como "Pedido Sob Medida Pendente".
 
-Quando prestadores se candidatarem, a seção "Propostas" aparece no dashboard. Para cada proposta, o cliente pode:
+Quando prestadores se candidatarem, a seção "Propostas" aparece na tela inicial. Para cada proposta, o cliente pode:
 - ACEITAR: confirmar a proposta e gerar o agendamento com aquele prestador.
 - RECUSAR: dispensar a proposta daquele prestador.
 
+O pagamento do Sob Medida segue as mesmas regras da seção 5 (PIX ou cartão de crédito, após o aceite).
+
 ------------------------------
-6. AGENDA
+7. AGENDA
 ------------------------------
 Acessada pelo menu inferior do aplicativo. Exibe o histórico e próximos agendamentos do cliente.
 
 Dividida em duas seções:
 
-6.1 PRÓXIMOS AGENDAMENTOS
+7.1 PRÓXIMOS AGENDAMENTOS
 Lista os agendamentos futuros ou em andamento, com:
 - Foto/inicial do prestador
 - Nome do prestador
 - Dia da semana, data, horário de início e fim
-- Status do agendamento (Pendente, Aceito, Pago, Em andamento, etc.)
+- Status do agendamento
 - Valor total e duração do serviço
 - Tipo de agendamento (Padrão ou Sob Medida)
 
 Ações disponíveis por agendamento:
 - VER DETALHES: clicar em "Ver Detalhes" → exibe informações completas do agendamento.
-- CANCELAR: dentro dos detalhes, clicar em "Cancelar Agendamento" → informar o motivo (mínimo 5 caracteres) → confirmar. Atenção: pode haver taxa de cancelamento dependendo da antecedência.
-- PEDIR AJUDA: clicar em "Ajuda" para acessar o suporte via chatbot.
+- CANCELAR: dentro dos detalhes, clicar em cancelar → informar o motivo (mínimo 5 caracteres) → confirmar.
+- PEDIR AJUDA: clicar em "Ajuda" para acessar o suporte.
+
+COMO FUNCIONA O CANCELAMENTO:
+- É possível cancelar enquanto o agendamento estiver Pendente, Aceito ou Pago. Depois que o serviço começa, não é mais possível cancelar pelo app.
+- É obrigatório informar o motivo, com no mínimo 5 caracteres.
+- ATENÇÃO — CANCELAMENTO EM CADEIA: se o agendamento fizer parte de um pedido com vários horários, cancelar um deles CANCELA TODOS OS AGENDAMENTOS FUTUROS DAQUELE PEDIDO. O próprio app exibe esse aviso antes de confirmar, informando quantos agendamentos serão afetados. Não é possível cancelar apenas um horário e manter os demais.
 
-6.2 AGENDAMENTOS CONCLUÍDOS
+Se o cliente perguntar sobre valores devolvidos, reembolso, estorno, prazo de devolução, multa ou taxa de cancelamento, NÃO informe valores, percentuais nem prazos. Explique que essas questões são tratadas pela equipe de suporte e oriente o cliente a usar o botão de atendente humano no topo da tela de Ajuda.
+
+7.2 AGENDAMENTOS CONCLUÍDOS
 Lista os serviços já finalizados, com as mesmas informações dos próximos agendamentos, além de:
 
 - AVALIAR PRESTADOR: clicar em "Avaliar" → dar uma nota de 1 a 5 estrelas → selecionar tags de avaliação (positivas ou negativas conforme a nota) → adicionar comentário (opcional) → adicionar fotos (opcional) → enviar. Cada agendamento pode ser avaliado uma única vez.
@@ -197,19 +296,22 @@ Lista os serviços já finalizados, com as mesmas informações dos próximos ag
 - RE-AGENDAR: quando disponível, clicar em "Reagendar" para iniciar um novo agendamento com o mesmo prestador.
 
 Status possíveis de um agendamento:
-- Pendente: aguardando aceite do prestador
-- Aceito: prestador aceitou, aguardando pagamento
+- Pendente: aguardando aceite do profissional
+- Aceito: profissional aceitou, aguardando pagamento
 - Pago: pagamento confirmado
 - Em andamento: serviço em execução
 - Concluído: serviço finalizado
-- Cancelado: cancelado pelo cliente ou prestador
-- Rejeitado: prestador recusou o pedido
+- Cancelado: cancelado pelo cliente ou pelo profissional
+- Rejeitado: profissional recusou o pedido
+
+Ao final do serviço, o cliente informa ao profissional o código de conclusão do agendamento, que confirma que o serviço foi realizado.
 
 ------------------------------
-7. NOTIFICAÇÕES
+8. NOTIFICAÇÕES
 ------------------------------
 Acessada pelo ícone de sino no topo da tela inicial. Exibe a lista de notificações do cliente, como:
-- Confirmação de agendamento pelo prestador
+- Aceite ou recusa do pedido pelo profissional
+- Confirmação de pagamento
 - Alterações de status dos agendamentos
 - Novas propostas de prestadores para pedidos sob medida
 - Lembretes de serviços próximos

+ 99 - 33
resources/chatbot/context_prestador.txt

@@ -10,6 +10,19 @@ REGRAS ESTRITAS:
 
 >>> As restrições de segurança e escopo aplicadas a este assistente estão definidas no arquivo restricoes.md, carregado automaticamente pelo sistema antes deste contexto. As seções aplicáveis a este app são: "SEGURANÇA ABSOLUTA" e "RESTRIÇÕES DE ESCOPO — APP PRESTADOR". <<<
 
+========================================================
+RESUMO RÁPIDO — COMO O APP FUNCIONA HOJE
+========================================================
+
+Este resumo vale mais do que qualquer suposição. Em caso de dúvida, siga o que está aqui.
+
+- Para RECEBER, o prestador cadastra CONTA BANCÁRIA. Não existe cadastro de chave PIX no app do prestador.
+- É possível cadastrar mais de uma conta bancária, mas só a CONTA PRINCIPAL recebe os saques.
+- A conta bancária precisa estar no CPF do próprio prestador.
+- Sem conta bancária cadastrada, o prestador NÃO aparece para os clientes na busca.
+- As solicitações podem chegar como PACOTE (vários serviços de um mesmo cliente). O aceite e a recusa valem para o pacote INTEIRO — não é possível aceitar apenas algumas datas.
+- O prestador não recebe automaticamente: ele SOLICITA O SAQUE quando o saldo estiver disponível.
+
 ========================================================
 FUNCIONALIDADES DISPONÍVEIS PARA PRESTADORES
 ========================================================
@@ -22,24 +35,36 @@ A tela inicial é o painel principal do prestador. Exibe as informações mais r
 - Resumo de métricas: total de serviços realizados, avaliação média e outros indicadores de desempenho.
 - Sugestão de preço: o sistema exibe uma sugestão de valor baseada no mercado local para ajudar o prestador a definir seu preço competitivo.
 - Serviços de hoje: lista dos agendamentos que acontecem no dia atual, com nome do cliente, horário e valor.
-- Solicitações pendentes: pedidos de agendamento enviados por clientes que ainda aguardam aceite ou recusa do prestador.
+- Solicitações para você: pedidos enviados por clientes que ainda aguardam aceite ou recusa.
 - Próximos agendamentos: serviços confirmados para os próximos dias.
-- Oportunidades (sob medida): pedidos abertos de clientes na região que o prestador pode se candidatar.
+- Oportunidades (sob medida): pedidos abertos de clientes na região aos quais o prestador pode se candidatar.
+
+Se o prestador ainda não tiver conta bancária principal cadastrada, o app exibe o aviso "Cadastre seus dados bancários", com a mensagem "Você precisa cadastrar seus dados bancários para aparecer para os clientes da plataforma." e os botões "Cadastrar dados bancários" e "Agora não".
 
 ------------------------------
 2. SOLICITAÇÕES DE AGENDAMENTO (receber e responder pedidos)
 ------------------------------
-Quando um cliente envia um pedido de agendamento, ele aparece na seção "Solicitações" da tela inicial.
+Quando um cliente envia um pedido, ele aparece na seção "Solicitações para você" da tela inicial.
+
+A solicitação pode chegar de duas formas:
+
+a) SERVIÇO ÚNICO: uma data e um horário.
+
+b) PACOTE: o cliente montou um pedido com vários serviços. O card mostra "Pacote com N serviços", lista todas as datas e horários e exibe o valor somado do pacote.
 
 O prestador pode:
-- VER DETALHES: clicar na solicitação para abrir os detalhes completos — nome e foto do cliente, data, horário de início e fim, valor total, taxa de serviço da plataforma, valor líquido a receber e tipos de serviço solicitados.
-- ACEITAR: confirmar a solicitação. Após o aceite, o cliente é notificado e o pagamento é processado. O agendamento passa a constar na agenda do prestador.
-- RECUSAR: declinar o pedido. O cliente é notificado e o pedido é encerrado.
+- VER DETALHES: clicar em "ver detalhes" para abrir as informações completas — nome e foto do cliente, cada data e horário do pacote, valor total e tipos de serviço solicitados.
+- ACEITAR: clicar em "aceitar" e confirmar.
+- RECUSAR: clicar em "recusar" e confirmar. O cliente é notificado.
+
+REGRA CENTRAL DOS PACOTES: o aceite e a recusa valem para o PACOTE INTEIRO. Não é possível aceitar algumas datas e recusar outras. Se o prestador só puder atender parte das datas, o caminho é recusar o pacote e pedir ao cliente, fora do fluxo de solicitação, que envie um novo pedido apenas com as datas possíveis. Ao recusar um pacote, todos os agendamentos dele são cancelados.
 
-Regras importantes:
-- O prestador pode ter no máximo 2 agendamentos por semana com o mesmo cliente.
-- Só aparecem solicitações para datas em que o prestador tem disponibilidade cadastrada e sem bloqueio de agenda.
+Outras regras importantes:
+- O prestador pode ter no máximo 2 agendamentos por semana (de domingo a sábado) com o mesmo cliente.
+- Só chegam solicitações para datas em que o prestador tem disponibilidade cadastrada e sem bloqueio de agenda.
 - Após aceitar, não é possível alterar data ou horário — apenas cancelar.
+- Depois do aceite, o cliente é quem realiza o pagamento. O serviço só fica totalmente confirmado quando o pagamento é aprovado.
+- Recusar solicitações com frequência pode reduzir a visibilidade do prestador no aplicativo.
 
 ------------------------------
 3. PERFIL
@@ -58,18 +83,37 @@ O prestador pode editar:
 Como editar: clicar em "Editar Perfil" → alterar os campos desejados → clicar em "Atualizar". O botão só fica ativo se houver alteração.
 
 3.2 DADOS BANCÁRIOS
-Onde o prestador cadastra as informações para receber pagamentos. Há dois tipos:
+Onde o prestador cadastra as contas bancárias para receber seus pagamentos.
 
-PIX:
-- Cadastrar ou atualizar a chave PIX (CPF, CNPJ, e-mail, telefone ou chave aleatória).
-- Clicar em "Salvar PIX" para registrar ou "Atualizar PIX" para modificar.
+Caminho: Perfil → "Dados bancários".
 
-Conta Bancária (TED):
-- Selecionar o tipo: Conta Corrente ou Conta Poupança.
-- Preencher agência, número da conta e dígito verificador.
-- Clicar em "Salvar Conta" para registrar ou "Atualizar Conta" para modificar.
+IMPORTANTE: o único meio de recebimento é CONTA BANCÁRIA. O aplicativo do prestador NÃO tem cadastro de chave PIX. Se o prestador perguntar como cadastrar sua chave PIX para receber, explique que hoje o recebimento é feito apenas por conta bancária e oriente o cadastro abaixo.
 
-Atenção: os dados bancários precisam estar corretos para que o saque seja processado com sucesso.
+COMO CADASTRAR UMA CONTA:
+1. Abrir Perfil → "Dados bancários".
+2. Clicar no botão redondo "+" ("Nova conta").
+3. Preencher:
+   - Banco: campo de busca, aceita o nome ou o código do banco.
+   - Tipo de conta: Corrente ou Poupança.
+   - Agência e o Dígito da agência (o dígito é opcional).
+   - Conta e o Dígito da conta (o dígito da conta é obrigatório).
+4. Clicar em "cadastrar conta".
+
+A conta precisa estar no CPF do próprio prestador — o titular é sempre ele. Não é pedido nome do titular nem CPF no formulário porque o sistema usa os dados do próprio cadastro.
+
+MÚLTIPLAS CONTAS:
+- É possível cadastrar quantas contas quiser. A primeira cadastrada vira automaticamente a principal.
+- Apenas a CONTA PRINCIPAL recebe os saques. Ela aparece na lista com o selo "Conta principal".
+- Para trocar a conta principal: clicar no ícone de lápis da conta desejada → marcar "Tornar esta a conta principal" → salvar.
+- Para editar uma conta: ícone de lápis. Para excluir: ícone de lixeira.
+
+RESTRIÇÕES:
+- Não é possível excluir a última conta: "Você precisa manter ao menos uma conta bancária cadastrada."
+- Não é possível excluir a conta principal: é preciso definir outra como principal antes.
+- Não é possível cadastrar a mesma conta duas vezes: "Esta conta bancária já está cadastrada."
+- Enquanto houver um saque em andamento, as contas ficam bloqueadas para alteração: "Você possui um saque em andamento. Aguarde a conclusão para alterar suas contas bancárias."
+
+Atenção: sem uma conta bancária cadastrada, o prestador não aparece para os clientes na busca e não consegue sacar. Os dados precisam estar corretos para que o saque seja processado com sucesso.
 
 3.3 DISPONIBILIDADE
 Onde o prestador configura quando está disponível para trabalhar. Tem duas partes:
@@ -86,7 +130,7 @@ BLOQUEIOS DE AGENDA (calendário):
 - Clicar em um dia abre um painel de ações:
   - Se o dia já tem bloqueio: exibe os bloqueios existentes (período e horário) com opção de "Desbloquear" cada um.
   - Se o dia não tem bloqueio: duas opções aparecem:
-    a) Bloquear o dia inteiro: fecha a agenda para aquela data completamente (nenhum cliente consegue agendar).
+    a) Bloquear o dia inteiro: fecha a agenda para aquela data completamente.
     b) Bloquear um período: escolher Manhã ou Tarde para bloquear apenas metade do dia.
 
 Importante: bloqueios não afetam agendamentos já confirmados, apenas impedem novos pedidos para aquela data.
@@ -103,14 +147,18 @@ PREÇO:
 - Os campos de 6h, 4h e 2h são somente leitura — não podem ser editados diretamente.
 - Clicar em "Atualizar" para salvar o novo preço.
 
+Os períodos aparecem para o cliente como: Integral (até 8h), Padrão (até 6h), Meio Período (até 4h) e Diária Rápida (até 2h).
+
+O preço vale para todos os clientes: não é possível definir valores diferentes por cliente ou por agendamento.
+
 TIPOS DE SERVIÇO:
-- Lista de tipos disponíveis na plataforma (ex.: limpeza padrão, limpeza pesada, etc.).
+- Lista de tipos disponíveis na plataforma.
 - O prestador marca (ativa) ou desmarca (desativa) cada tipo conforme os serviços que oferece.
 - A alteração é salva automaticamente ao marcar/desmarcar cada opção.
 - Apenas serviços marcados aparecem para os clientes ao agendar.
 
 3.5 ENDEREÇO
-Cadastro e gerenciamento do endereço do prestador. Funciona da mesma forma que o endereço do cliente:
+Cadastro e gerenciamento do endereço do prestador:
 - VISUALIZAR: lista todos os endereços cadastrados com tipo, logradouro, número, bairro, cidade e estado.
 - ADICIONAR: informar CEP (preenchimento automático) → número, complemento e tipo → salvar.
 - EDITAR: acessar pelo menu "..." → alterar dados → salvar.
@@ -125,7 +173,7 @@ Lista de clientes que o prestador bloqueou.
 Como bloquear um cliente: dentro do detalhe de um agendamento, há a opção de bloquear aquele cliente. Clientes bloqueados não conseguem mais enviar solicitações ao prestador.
 
 3.7 AJUDA
-Acesso ao assistente virtual do aplicativo (este chatbot).
+Acesso ao assistente virtual do aplicativo (este chatbot). No topo desta tela há o botão de atendente humano.
 
 3.8 POLÍTICA DE PRIVACIDADE
 Abre a página de política de privacidade do aplicativo no navegador.
@@ -153,10 +201,17 @@ Lista os serviços futuros ou em andamento, com:
 - Tipo: Padrão ou Sob Medida
 
 Ações disponíveis:
-- VER DETALHES: abre informações completas do agendamento — cliente, data, horário, valor total, taxa de serviço, valor líquido, endereço do serviço e se o cliente oferece refeição.
+- VER DETALHES: abre informações completas do agendamento — cliente, data, horário, valor, endereço do serviço e se o cliente oferece refeição.
 - COPIAR ENDEREÇO: dentro dos detalhes, copiar o endereço do serviço para a área de transferência.
-- CANCELAR: dentro dos detalhes, clicar em "Cancelar Serviço" → informar o motivo (mínimo 5 caracteres) → confirmar. Atenção: cancelamentos podem afetar a avaliação do prestador e há regras de taxa dependendo da antecedência.
-- PEDIR AJUDA: acessar o chatbot de suporte.
+- CANCELAR: dentro dos detalhes, clicar em cancelar → informar o motivo (mínimo 5 caracteres) → confirmar.
+- PEDIR AJUDA: acessar o assistente de suporte.
+
+SOBRE O CANCELAMENTO:
+- É possível cancelar enquanto o agendamento estiver Pendente, Aceito ou Pago. Depois que o serviço começa, não é mais possível cancelar pelo app.
+- ATENÇÃO — CANCELAMENTO EM CADEIA: se o agendamento fizer parte de um pacote, cancelar um deles CANCELA TODOS OS AGENDAMENTOS FUTUROS DAQUELE PACOTE. O app exibe esse aviso antes de confirmar. Não é possível cancelar apenas uma data e manter as demais.
+- Cancelar serviços já agendados pode ocasionar bloqueio temporário ou permanente do perfil no aplicativo.
+
+CONCLUSÃO DO SERVIÇO: ao terminar o serviço, o cliente informa ao prestador o código de conclusão do agendamento. Essa confirmação é o que libera o valor do serviço para o saldo do prestador.
 
 4.2 AGENDAMENTOS CONCLUÍDOS
 Lista os serviços já finalizados, com as mesmas informações dos próximos agendamentos, além de:
@@ -182,14 +237,23 @@ Acessada pelo menu inferior. Central financeira do prestador.
 - Filtros de período: Semana (7 dias), Mês (30 dias) ou Ano (365 dias).
 - Exibe o valor total e a quantidade de serviços realizados no período.
 
-5.2 SALDO DISPONÍVEL
-- Valor já liberado e disponível para saque imediato.
-- Botão "Sacar": clicar → confirmar o valor → o saque é solicitado e processado via PIX ou conta bancária cadastrada. O saldo disponível zera após a solicitação.
-- O saldo fica disponível após o serviço ser concluído e o prazo de liberação passar.
+5.2 SALDO DISPONÍVEL E SAQUE
+- "Saldo disponível" é o valor já liberado, pronto para ser sacado.
+- O recebimento NÃO é automático: o prestador precisa solicitar o saque.
+- Como sacar: na tela de Pagamentos, clicar em "sacar" → a tela "Confirmar saque" mostra o saldo disponível, o valor descontado da transferência e o valor final que será recebido → clicar em "confirmar". Mensagem de sucesso: "Saque solicitado com sucesso."
+- O saque é enviado para a CONTA BANCÁRIA PRINCIPAL cadastrada. Não é possível sacar por PIX nem escolher outra conta no momento do saque — para trocar, é preciso alterar qual conta é a principal antes de solicitar (ver 3.2).
+- O saldo disponível zera assim que o saque é solicitado.
+- Só é possível ter UM saque em andamento por vez: "Você já possui um saque em andamento."
+- Se o saldo for zero ou insuficiente, o app avisa e o saque não é feito.
+- Se a conta principal estiver com dados desatualizados, o app pede para revisar os dados bancários antes de sacar.
 
 5.3 SALDO A LIBERAR (pendente)
-- Valor de serviços já pagos mas ainda em prazo de liberação.
-- Torna-se disponível automaticamente após o prazo definido pela plataforma.
+- Valor de serviços já pagos pelo cliente, mas ainda em prazo de liberação.
+- Para o valor entrar no saldo disponível são necessárias duas condições:
+  a) o serviço precisa estar concluído e o cliente precisa ter informado o código de conclusão; e
+  b) o prazo de liberação precisa ter passado — em até 5 dias após a conclusão do serviço.
+- Quando o serviço faz parte de um PACOTE, o valor só entra no saldo disponível depois que TODOS os serviços daquele pacote estiverem concluídos e liberados.
+- A liberação é automática: não há nada a fazer para acelerar.
 - Não é possível antecipar ou sacar o saldo pendente antes da liberação.
 
 5.4 LISTA DE SERVIÇOS
@@ -197,7 +261,9 @@ Acessada pelo menu inferior. Central financeira do prestador.
 - Status de pagamento: Pendente, Pago, Autorizado, Processando, Falhou, Cancelado.
 
 5.5 MOVIMENTAÇÕES
-- Botão "Ver Movimentações" → abre um histórico completo de todas as transações financeiras do prestador na plataforma.
+- Link "ver movimentações" → abre o histórico completo das transações financeiras do prestador na plataforma.
+
+Se o prestador perguntar sobre percentuais, taxas ou como o valor final é composto, não informe percentuais nem faça cálculos. Oriente-o a conferir os valores exibidos nos detalhes do agendamento e na tela "Confirmar saque". Para contestações, oriente o uso do botão de atendente humano.
 
 ------------------------------
 6. OPORTUNIDADES (Agendamentos Sob Medida)
@@ -221,7 +287,7 @@ Ações disponíveis:
 - VER DETALHES: clicar em "Detalhes" para abrir as informações completas da oportunidade.
 - CANDIDATAR-SE: dentro dos detalhes, manifestar interesse no pedido. O cliente receberá a proposta e poderá aceitar ou recusar.
 
-Importante: o prestador só aparece como candidato se seu preço estiver dentro da faixa de preço definida pelo cliente e se estiver disponível na data e horário solicitados.
+Importante: o prestador só aparece como candidato se seu preço estiver dentro da faixa definida pelo cliente e se estiver disponível na data e horário solicitados. Serviços sob medida não fazem parte de pacotes.
 
 ------------------------------
 7. NOTIFICAÇÕES

+ 39 - 8
resources/chatbot/restricoes.md

@@ -70,11 +70,32 @@ Sempre que o usuário pedir para falar com um atendente humano, com o suporte, c
 COMO ORIENTAR (responda de forma natural, com este conteúdo):
 "Para falar com um atendente humano, toque no botão de atendente — o ícone de uma pessoa com fone de ouvido — no canto superior direito, aqui no topo desta conversa. Vai abrir um formulário rápido para você enviar sua solicitação, e nossa equipe de suporte entrará em contato com você."
 
+Existe também um segundo caminho: o botão "Falar com um humano", entre as sugestões rápidas exibidas nesta tela de chat. Ele abre o mesmo formulário.
+
 REGRAS:
 - NÃO peça dados pessoais nem tente coletar a mensagem ou o problema você mesmo; apenas direcione o usuário ao botão de atendente no cabeçalho.
 - Esse botão é o canal OFICIAL de contato com o suporte e fica SEMPRE visível no topo desta tela de chat.
 - Não execute nem prometa executar o contato; apenas oriente o usuário a usar o botão.
 
+========================================================
+VALORES, TAXAS E REEMBOLSOS
+========================================================
+
+Esta seção se aplica a AMBOS os apps (Cliente e Prestador).
+
+O assistente NUNCA deve:
+- Citar percentuais de taxa de serviço da plataforma
+- Detalhar como o valor final é composto ou de onde vem a diferença entre um total e outro
+- Fazer cálculos de valores, descontos, taxas ou repasses
+- Informar valores devolvidos, percentuais de reembolso, prazos de estorno ou taxas de cancelamento
+
+O que o assistente PODE fazer:
+- Explicar onde o usuário vê os valores: tela de Pedidos, tela de Pagamento e detalhes do agendamento (cliente); detalhes do agendamento e tela "Confirmar saque" (prestador).
+- Informar que pedidos com 3 ou mais horários recebem desconto no total, sem citar percentuais nem calcular o valor.
+- Explicar o passo a passo do cancelamento e o efeito em cadeia dentro de um pedido/pacote.
+
+Sempre que a pergunta envolver quanto será devolvido, reembolso, estorno, cobrança indevida, multa por cancelamento ou contestação de valores, aplicar a orientação da seção "ATENDIMENTO HUMANO" e direcionar o usuário ao botão de atendente.
+
 ========================================================
 RESTRIÇÕES DE ESCOPO — APP CLIENTE
 ========================================================
@@ -82,7 +103,7 @@ RESTRIÇÕES DE ESCOPO — APP CLIENTE
 Aplicar apenas quando o assistente estiver operando no contexto do App Cliente.
 
 PRINCÍPIO FUNDAMENTAL:
-Antes de responder qualquer pergunta, verificar internamente se ela se encaixa em pelo menos um dos blocos de funcionalidades do App Cliente (Dashboard, Perfil, Busca, Agendamento Padrão, Agendamento Sob Medida, Agenda ou Notificações). Se não se encaixar em nenhum bloco, aplicar a resposta padrão de escopo.
+Antes de responder qualquer pergunta, verificar internamente se ela se encaixa em pelo menos um dos blocos de funcionalidades do App Cliente (Dashboard, Perfil, Busca, Agendamento Padrão, Pedidos/Carrinho, Pagamento, Agendamento Sob Medida, Agenda ou Notificações). Se não se encaixar em nenhum bloco, aplicar a resposta padrão de escopo.
 
 RESPOSTA PADRÃO PARA PERGUNTAS FORA DO ESCOPO:
 "Não consigo ajudar com isso por aqui. Para mais informações, entre em contato com o administrador do aplicativo."
@@ -118,8 +139,11 @@ Proibido orientar sobre:
 - Como resolver problemas com cobrança indevida
 - Valores cobrados incorretamente
 - Disputas de pagamento com o prestador
+- Quanto será devolvido ao cancelar um agendamento, prazos e taxas de cancelamento
+
+Para esses casos, seguir a seção "VALORES, TAXAS E REEMBOLSOS" e direcionar o usuário ao botão de atendente humano.
 
-Para esses casos, aplicar resposta padrão indicando contato com o administrador.
+Observação: explicar COMO cancelar um agendamento é permitido e está documentado no contexto do app. O que é proibido é falar sobre os valores envolvidos no cancelamento.
 
 ------------------------------
 R5. SUPORTE TÉCNICO DE ERROS E BUGS
@@ -130,7 +154,7 @@ Proibido tentar diagnosticar ou resolver:
 - Falhas de carregamento, tela branca ou comportamentos inesperados
 - Problemas de conexão, sincronização ou notificações não chegando
 
-Para esses casos, aplicar resposta padrão indicando contato com o administrador.
+Para esses casos, direcionar o usuário ao botão de atendente humano, conforme a seção "ATENDIMENTO HUMANO".
 
 ------------------------------
 R6. DADOS PESSOAIS DE TERCEIROS
@@ -147,7 +171,7 @@ Proibido orientar sobre:
 - Como negociar preço diretamente com o prestador fora do aplicativo
 - Como combinar serviços ou condições fora da plataforma
 - Desconto, promoção ou condição especial não prevista no app
-- Forma de pagamento diferente do cartão de crédito cadastrado no app
+- Forma de pagamento diferente de PIX ou cartão de crédito dentro do app (não existe boleto, débito, dinheiro, transferência, carteira digital nem pagamento direto ao prestador)
 
 ------------------------------
 R8. EXCLUSÃO DE CONTA
@@ -176,11 +200,14 @@ R11. AÇÕES QUE O SISTEMA NÃO PERMITE AO CLIENTE
 Proibido simular ou sugerir que o cliente pode realizar ações que o sistema não oferece, como:
 - Alterar data ou horário de um agendamento já confirmado (só é possível cancelar e refazer)
 - Contatar diretamente o prestador pelo aplicativo (não há chat entre cliente e prestador)
-- Escolher qual cartão será usado por agendamento (o sistema usa o cadastrado)
 - Ver o endereço completo do prestador (o app exibe apenas bairro e distância)
-- Criar agendamentos recorrentes automáticos (cada agendamento é individual)
+- Criar agendamentos recorrentes automáticos (cada horário é adicionado individualmente ao pedido)
 - Agendar para datas passadas
 - Fazer mais de 2 agendamentos por semana com o mesmo prestador
+- Montar um único pedido com horários de dois profissionais diferentes
+- Manter dois pedidos abertos ao mesmo tempo
+- Pagar apenas um horário de um pedido que tem vários (o pagamento é sempre do pedido inteiro)
+- Cancelar apenas um horário de um pedido sem afetar os demais (o cancelamento atinge todos os agendamentos futuros daquele pedido)
 
 ------------------------------
 R12. SOLICITAÇÕES DE AÇÃO DIRETA NO SISTEMA
@@ -195,7 +222,7 @@ RESTRIÇÕES DE ESCOPO — APP PRESTADOR
 Aplicar apenas quando o assistente estiver operando no contexto do App Prestador.
 
 PRINCÍPIO FUNDAMENTAL:
-Antes de responder qualquer pergunta, verificar internamente se ela se encaixa em pelo menos um dos blocos de funcionalidades do App Prestador (Dashboard, Solicitações, Perfil, Agenda, Pagamentos, Oportunidades ou Notificações). Se não se encaixar em nenhum bloco, aplicar a resposta padrão de escopo.
+Antes de responder qualquer pergunta, verificar internamente se ela se encaixa em pelo menos um dos blocos de funcionalidades do App Prestador (Dashboard, Solicitações, Pacotes, Perfil, Dados bancários, Disponibilidade, Agenda, Pagamentos e Saque, Oportunidades ou Notificações). Se não se encaixar em nenhum bloco, aplicar a resposta padrão de escopo.
 
 RESPOSTA PADRÃO PARA PERGUNTAS FORA DO ESCOPO:
 "Não consigo ajudar com isso por aqui. Para mais informações, entre em contato com o administrador do aplicativo."
@@ -223,7 +250,7 @@ Qualquer pergunta relacionada ao painel administrativo, gestão de usuários, re
 ------------------------------
 R4. DISPUTAS E CONTESTAÇÕES FINANCEIRAS
 ------------------------------
-Proibido orientar sobre como contestar a taxa de serviço da plataforma, solicitar reembolso ou reclamar de cobrança. Para esses casos, aplicar resposta padrão indicando contato com o administrador.
+Proibido orientar sobre como contestar a taxa de serviço da plataforma, solicitar reembolso ou reclamar de cobrança, e proibido citar percentuais ou detalhar a composição dos valores. Para esses casos, seguir a seção "VALORES, TAXAS E REEMBOLSOS" e direcionar o usuário ao botão de atendente humano.
 
 ------------------------------
 R5. SUPORTE TÉCNICO DE ERROS E BUGS
@@ -261,6 +288,10 @@ Proibido simular ou sugerir que o prestador pode realizar ações que o sistema
 - Criar agendamentos por iniciativa própria (apenas receber e aceitar solicitações)
 - Aceitar mais de 2 agendamentos por semana com o mesmo cliente
 - Antecipar ou sacar saldo pendente antes do prazo de liberação
+- Aceitar ou recusar apenas alguns agendamentos de um pacote (o aceite e a recusa valem para o pacote inteiro)
+- Cadastrar chave PIX para receber (o recebimento é somente por conta bancária)
+- Sacar para uma conta que não seja a conta principal, ou receber por PIX
+- Ter mais de um saque em andamento ao mesmo tempo
 
 ------------------------------
 R11. SOLICITAÇÕES DE AÇÃO DIRETA NO SISTEMA

+ 3 - 1
routes/authRoutes/dashboard.php

@@ -7,4 +7,6 @@ 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-dashboard-prestador', [DashboardController::class, 'dadosDashboardPrestador'])->middleware('permission:dashboard,view');
+Route::get('/dados-dashboard-prestador', [DashboardController::class, 'dadosDashboardPrestador'])
+    ->middleware('permission:dashboard,view')
+    ->withoutMiddleware('provider.accepted');

+ 3 - 6
routes/authRoutes/provider.php

@@ -11,14 +11,11 @@ Route::get('/provider/payment-splits',            [ProviderWithdrawalController:
 Route::get('/provider-withdrawals',               [ProviderWithdrawalController::class, 'all'])->middleware('permission:payment,view');
 Route::get('/provider/withdrawals/balance',       [ProviderWithdrawalController::class, 'balance'])->middleware('permission:config.provider,view');
 Route::get('/provider/withdrawals/fees',          [ProviderWithdrawalController::class, 'fees'])->middleware('permission:config.provider,view');
-Route::get('/provider/withdrawals/config',        [ProviderWithdrawalController::class, 'configuration']);
 Route::get('/provider/withdrawals',               [ProviderWithdrawalController::class, 'index'])->middleware('permission:config.provider,view');
 Route::post('/provider/withdrawals',              [ProviderWithdrawalController::class, 'store'])->middleware('permission:config.provider,edit');
 Route::get('/provider/withdrawals/{id}',          [ProviderWithdrawalController::class, 'show'])->middleware('permission:config.provider,view');
-Route::get('/provider/payment-methods/{id}',      [ProviderController::class, 'paymentMethods'])->middleware('permission:config.provider,view');
 Route::get('/provider',                           [ProviderController::class, 'index'])->middleware('permission:config.provider,view');
 Route::post('/provider',                          [ProviderController::class, 'store'])->middleware('permission:config.provider,add');
-Route::get('/provider/{id}',                      [ProviderController::class, 'show'])->middleware('permission:config.provider,view');
-Route::put('/provider/{id}',                      [ProviderController::class, 'update'])->middleware('permission:config.provider,edit');
-Route::patch('/provider/{id}/bank-account',       [ProviderController::class, 'updateBankAccount'])->middleware('permission:config.provider,edit');
-Route::delete('/provider/{id}',                   [ProviderController::class, 'destroy'])->middleware('permission:config.provider,delete');
+Route::get('/provider/{id}',                      [ProviderController::class, 'show'])->whereNumber('id')->middleware('permission:config.provider,view');
+Route::put('/provider/{id}',                      [ProviderController::class, 'update'])->whereNumber('id')->middleware('permission:config.provider,edit');
+Route::delete('/provider/{id}',                   [ProviderController::class, 'destroy'])->whereNumber('id')->middleware('permission:config.provider,delete');

+ 14 - 0
routes/authRoutes/provider_bank_account.php

@@ -0,0 +1,14 @@
+<?php
+
+use App\Http\Controllers\ProviderBankAccountController;
+use Illuminate\Support\Facades\Route;
+
+// contas do proprio prestador autenticado (o provider nunca vem da URL)
+Route::get('/provider/bank-accounts',                 [ProviderBankAccountController::class, 'index'])->middleware('permission:config.provider,view');
+Route::post('/provider/bank-accounts',                [ProviderBankAccountController::class, 'store'])->middleware('permission:config.provider,edit');
+// a troca da conta principal acontece na propria edicao da conta (campo is_primary)
+Route::put('/provider/bank-accounts/{id}',            [ProviderBankAccountController::class, 'update'])->middleware('permission:config.provider,edit');
+Route::delete('/provider/bank-accounts/{id}',         [ProviderBankAccountController::class, 'destroy'])->middleware('permission:config.provider,edit');
+
+// leitura pelo backoffice (admin nao altera dados bancarios)
+Route::get('/provider/{id}/bank-accounts',            [ProviderBankAccountController::class, 'forProvider'])->middleware('permission:config.provider,view');

+ 2 - 1
routes/authRoutes/search.php

@@ -3,4 +3,5 @@
 use App\Http\Controllers\SearchController;
 use Illuminate\Support\Facades\Route;
 
-Route::get('/prestadores-busca', [SearchController::class, 'buscaPrestadores'])->middleware('permission:dashboard,view');
+Route::get('/prestadores-busca', [SearchController::class, 'buscaPrestadores'])->middleware('permission:dashboard,view');
+Route::get('/prestador-perfil/{id}', [SearchController::class, 'perfilPrestador'])->middleware('permission:dashboard,view');

+ 206 - 0
tests/Feature/ProviderApprovalPushTest.php

@@ -0,0 +1,206 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Enums\ApprovalStatusEnum;
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\DeviceToken;
+use App\Models\Provider;
+use App\Models\PushNotificationLog;
+use App\Models\User;
+use App\Notifications\Push\Prestador\Transacional\CadastroAprovadoPush;
+use App\Services\ProviderService;
+use App\Services\PushNotificationService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Mail;
+use Mockery\MockInterface;
+use Tests\TestCase;
+
+class ProviderApprovalPushTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private ProviderService $service;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        Mail::fake();
+
+        $this->service = app(ProviderService::class);
+    }
+
+    public function test_aprovar_prestador_dispara_push_de_cadastro_aprovado(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user);
+
+        $push = $this->mockPushService();
+
+        $push->shouldReceive('sendToUser')
+            ->once()
+            ->withArgs(function (User $user, $notification) use ($provider) {
+                return $user->is($provider->user)
+                    && $notification instanceof CadastroAprovadoPush;
+            });
+
+        $approved = $this->service->approve($provider->id);
+
+        $this->assertSame(ApprovalStatusEnum::ACCEPTED->value, $approved->approval_status->value);
+    }
+
+    public function test_update_para_accepted_dispara_push(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user);
+
+        $push = $this->mockPushService();
+
+        $push->shouldReceive('sendToUser')->once();
+
+        $this->service->update($provider->id, [
+            'approval_status' => ApprovalStatusEnum::ACCEPTED->value,
+        ]);
+    }
+
+    public function test_update_que_nao_muda_o_status_nao_dispara_push(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user);
+
+        $push = $this->mockPushService();
+
+        $push->shouldNotReceive('sendToUser');
+
+        $this->service->update($provider->id, ['birth_date' => '1991-02-02']);
+    }
+
+    public function test_aprovar_prestador_ja_aprovado_nao_reenvia_push(): void
+    {
+        $provider = $this->makeProvider(ApprovalStatusEnum::ACCEPTED);
+
+        $this->withDeviceToken($provider->user);
+
+        $push = $this->mockPushService();
+
+        $push->shouldNotReceive('sendToUser');
+
+        $this->service->approve($provider->id);
+    }
+
+    /**
+     * Rede de seguranca extra: mesmo que a transicao seja detectada de novo
+     * (reprovar e reaprovar, por exemplo), o log impede o envio duplicado.
+     */
+    public function test_push_ja_registrada_no_log_nao_e_reenviada(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user);
+
+        PushNotificationLog::query()->create([
+            'label'    => (new CadastroAprovadoPush)->label(),
+            'user_id'  => $provider->user->id,
+            'target'   => PushNotificationTargetEnum::PRESTADOR->value,
+            'category' => PushNotificationCategoryEnum::TRANSACIONAL->value,
+            'sent_at'  => now()->subDay(),
+        ]);
+
+        $push = $this->mockPushService();
+
+        $push->shouldNotReceive('sendToUser');
+
+        $this->service->approve($provider->id);
+    }
+
+    public function test_prestador_sem_device_token_ativo_nao_dispara_push(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user, active: false);
+
+        $push = $this->mockPushService();
+
+        $push->shouldNotReceive('sendToUser');
+
+        $approved = $this->service->approve($provider->id);
+
+        $this->assertSame(ApprovalStatusEnum::ACCEPTED->value, $approved->approval_status->value);
+    }
+
+    public function test_token_de_outro_app_nao_dispara_push_do_prestador(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user, appType: PushNotificationTargetEnum::CLIENTE);
+
+        $push = $this->mockPushService();
+
+        $push->shouldNotReceive('sendToUser');
+
+        $this->service->approve($provider->id);
+    }
+
+    public function test_falha_no_envio_da_push_nao_impede_a_aprovacao(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->withDeviceToken($provider->user);
+
+        $push = $this->mockPushService();
+
+        $push->shouldReceive('sendToUser')
+            ->once()
+            ->andThrow(new \RuntimeException('FCM indisponivel'));
+
+        $approved = $this->service->approve($provider->id);
+
+        $this->assertSame(ApprovalStatusEnum::ACCEPTED->value, $approved->approval_status->value);
+    }
+
+    //
+
+    private function mockPushService(): MockInterface
+    {
+        return $this->mock(PushNotificationService::class);
+    }
+
+    private function makeProvider(?ApprovalStatusEnum $status = null): Provider
+    {
+        $user = User::query()->create([
+            'name'     => 'Prestador Teste',
+            'email'    => 'prestador'.uniqid().'@teste.com',
+            'password' => 'secret',
+            'type'     => UserTypeEnum::PROVIDER->value,
+        ]);
+
+        $provider = Provider::query()->create([
+            'user_id'         => $user->id,
+            'document'        => '06767310905',
+            'birth_date'      => '1990-01-01',
+            'approval_status' => ($status ?? ApprovalStatusEnum::PENDING)->value,
+        ]);
+
+        return $provider->load('user');
+    }
+
+    private function withDeviceToken(
+        User $user,
+        bool $active = true,
+        ?PushNotificationTargetEnum $appType = null,
+    ): DeviceToken {
+        return DeviceToken::query()->create([
+            'user_id'  => $user->id,
+            'token'    => 'tok_'.uniqid(),
+            'platform' => 'android',
+            'app_type' => ($appType ?? PushNotificationTargetEnum::PRESTADOR)->value,
+            'active'   => $active,
+        ]);
+    }
+}

+ 291 - 0
tests/Feature/ProviderBankAccountTest.php

@@ -0,0 +1,291 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Enums\ProviderWithdrawalStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Exceptions\BankAccountException;
+use App\Models\Bank;
+use App\Models\Provider;
+use App\Models\ProviderWithdrawal;
+use App\Models\User;
+use App\Services\ProviderBankAccountService;
+use App\Services\ProviderWithdrawalService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\Client\Factory;
+use Illuminate\Support\Facades\Http;
+use Tests\TestCase;
+
+class ProviderBankAccountTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private ProviderBankAccountService $service;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->fakePagarme();
+
+        $this->service = app(ProviderBankAccountService::class);
+
+        Bank::query()->create(['code' => '260', 'name' => 'Nu Pagamentos S.A.', 'active' => true]);
+        Bank::query()->create(['code' => '001', 'name' => 'Banco do Brasil S.A.', 'active' => true]);
+    }
+
+    /**
+     * Regressao: com a chave de idempotencia derivada do payload, voltar para a conta A
+     * reenviava a chave da primeira troca e o Pagar.me nao aplicava a alteracao.
+     */
+    public function test_alternar_conta_principal_a_b_a_usa_chaves_de_idempotencia_distintas(): void
+    {
+        $provider = $this->makeProvider();
+
+        $accountA = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+        $accountB = $this->service->create($provider, $this->accountData('001', '2222', '222222'));
+
+        $this->assertTrue($accountA->is_primary, 'a primeira conta cadastrada deve nascer principal');
+        $this->assertFalse($accountB->is_primary, 'a segunda conta nao pode virar principal sozinha');
+
+        $this->service->update($provider, $accountB->id, ['is_primary' => true]);
+        $this->service->update($provider, $accountA->id, ['is_primary' => true]);
+
+        $keys = $this->defaultBankAccountIdempotencyKeys();
+
+        $this->assertCount(3, $keys, 'cada troca/edicao da conta principal precisa chegar ao Pagar.me');
+        $this->assertCount(3, array_unique($keys), 'as chaves de idempotencia precisam ser distintas: '.implode(' | ', $keys));
+
+        $provider->refresh();
+
+        $this->assertSame('111111', data_get($provider->recipient_default_bank_account, 'account_number'));
+        $this->assertTrue($provider->primaryBankAccount()->first()->is($accountA));
+    }
+
+    public function test_conta_secundaria_nao_dispara_chamada_ao_pagarme(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+
+        $callsAfterFirstAccount = count($this->defaultBankAccountIdempotencyKeys());
+
+        $secondary = $this->service->create($provider, $this->accountData('001', '2222', '222222'));
+
+        $this->service->update($provider, $secondary->id, ['branch_number' => '3333']);
+
+        $this->assertSame(
+            $callsAfterFirstAccount,
+            count($this->defaultBankAccountIdempotencyKeys()),
+            'criar ou editar conta nao principal e operacao local',
+        );
+    }
+
+    public function test_editar_conta_principal_ressincroniza_com_o_pagarme(): void
+    {
+        $provider = $this->makeProvider();
+
+        $account = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+
+        $before = count($this->defaultBankAccountIdempotencyKeys());
+
+        $updated = $this->service->update($provider, $account->id, ['account_number' => '999999']);
+
+        $this->assertCount($before + 1, $this->defaultBankAccountIdempotencyKeys());
+        $this->assertTrue($updated->isSyncedWithGateway());
+
+        $provider->refresh();
+
+        $this->assertSame('999999', data_get($provider->recipient_default_bank_account, 'account_number'));
+    }
+
+    public function test_marcar_como_principal_na_edicao_troca_a_conta_do_recebedor(): void
+    {
+        $provider = $this->makeProvider();
+
+        $accountA = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+        $accountB = $this->service->create($provider, $this->accountData('001', '2222', '222222'));
+
+        $updated = $this->service->update($provider, $accountB->id, [
+            'branch_number' => '3333',
+            'is_primary'    => true,
+        ]);
+
+        $this->assertTrue($updated->is_primary);
+        $this->assertFalse($accountA->fresh()->is_primary, 'a conta anterior precisa deixar de ser principal');
+
+        $provider->refresh();
+
+        $this->assertSame('222222', data_get($provider->recipient_default_bank_account, 'account_number'));
+        $this->assertSame('3333', data_get($provider->recipient_default_bank_account, 'branch_number'));
+    }
+
+    public function test_nao_permite_excluir_a_conta_principal(): void
+    {
+        $provider = $this->makeProvider();
+
+        $primary = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+
+        $this->service->create($provider, $this->accountData('001', '2222', '222222'));
+
+        $this->expectException(BankAccountException::class);
+        $this->expectExceptionMessage(__('messages.bank_account_primary_requires_replacement'));
+
+        $this->service->delete($provider, $primary->id);
+    }
+
+    public function test_nao_permite_excluir_a_ultima_conta(): void
+    {
+        $provider = $this->makeProvider();
+
+        $account = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+
+        $this->expectException(BankAccountException::class);
+        $this->expectExceptionMessage(__('messages.bank_account_last_cannot_be_deleted'));
+
+        $this->service->delete($provider, $account->id);
+    }
+
+    public function test_nao_permite_alterar_contas_com_saque_em_andamento(): void
+    {
+        $provider = $this->makeProvider();
+
+        $primary   = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+        $secondary = $this->service->create($provider, $this->accountData('001', '2222', '222222'));
+
+        ProviderWithdrawal::query()->create([
+            'provider_id'     => $provider->id,
+            'recipient_id'    => $provider->recipient_id,
+            'idempotency_key' => 'withdrawal-test-'.$provider->id,
+            'gross_amount'    => 100,
+            'net_amount'      => 96,
+            'status'          => ProviderWithdrawalStatusEnum::PROCESSING,
+        ]);
+
+        try {
+            $this->service->update($provider, $secondary->id, ['is_primary' => true]);
+
+            $this->fail('trocar a conta principal deveria ser bloqueado durante um saque');
+        } catch (BankAccountException $e) {
+            $this->assertSame(__('messages.bank_account_blocked_by_withdrawal'), $e->getMessage());
+        }
+
+        $this->assertTrue($provider->primaryBankAccount()->first()->is($primary));
+
+        $this->expectException(BankAccountException::class);
+        $this->expectExceptionMessage(__('messages.bank_account_blocked_by_withdrawal'));
+
+        $this->service->delete($provider, $secondary->id);
+    }
+
+    public function test_conta_divergente_no_gateway_e_reenviada_antes_do_saque(): void
+    {
+        $provider = $this->makeProvider();
+
+        $account = $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+
+        // o recebedor esta com outra conta (ex: PATCH aplicado e commit local revertido)
+        $this->fakePagarme([
+            '*/recipients/re_*' => Http::response([
+                'id'                   => $provider->recipient_id,
+                'default_bank_account' => ['bank' => '001', 'branch_number' => '9999', 'account_number' => '888888', 'account_check_digit' => '7', 'type' => 'checking'],
+            ], 200),
+        ]);
+
+        $this->service->ensureGatewayMatchesPrimary($provider);
+
+        $this->assertCount(
+            1,
+            $this->defaultBankAccountIdempotencyKeys(),
+            'a conta principal local precisa ser reenviada quando o gateway diverge',
+        );
+
+        $this->assertTrue($account->fresh()->isSyncedWithGateway());
+    }
+
+    public function test_saque_bloqueado_quando_nao_e_possivel_sincronizar_a_conta(): void
+    {
+        $provider = $this->makeProvider();
+
+        $this->service->create($provider, $this->accountData('260', '1111', '111111'));
+
+        $this->fakePagarme([
+            '*/recipients/*/default-bank-account' => Http::response(['message' => 'erro'], 500),
+            '*/recipients/re_*'                   => Http::response([
+                'id'                   => $provider->recipient_id,
+                'default_bank_account' => ['bank' => '001', 'branch_number' => '9999', 'account_number' => '888888', 'account_check_digit' => '7', 'type' => 'checking'],
+            ], 200),
+        ]);
+
+        $this->expectException(BankAccountException::class);
+        $this->expectExceptionMessage(__('messages.provider_requires_synced_bank_account'));
+
+        app(ProviderWithdrawalService::class)->requestWithdrawal($provider->fresh());
+    }
+
+    //
+
+    /**
+     * Os stubs sao avaliados na ordem do array, entao os overrides vem primeiro.
+     * Atencao: cada chamada tambem zera as requisicoes ja gravadas.
+     */
+    private function fakePagarme(array $overrides = []): void
+    {
+        // Http::fake() acumula stubs e os antigos tem precedencia, entao a factory
+        // precisa ser descartada para um teste conseguir sobrescrever o stub do setUp
+        Http::clearResolvedInstances();
+        $this->app->forgetInstance(Factory::class);
+        Http::preventStrayRequests();
+
+        $defaults = [
+            '*/recipients/*/default-bank-account' => Http::response(['id' => 're_fake'], 200),
+            '*/recipients*'                       => Http::response(['id' => 're_fake'], 200),
+            '*'                                   => Http::response([], 200),
+        ];
+
+        // o "+" mantem os overrides na frente e sem colisao de chave com os defaults
+        Http::fake($overrides + array_diff_key($defaults, $overrides));
+    }
+
+    private function makeProvider(): Provider
+    {
+        $user = User::query()->create([
+            'name'     => 'Prestador Teste',
+            'email'    => 'prestador'.uniqid().'@teste.com',
+            'password' => 'secret',
+            'type'     => UserTypeEnum::PROVIDER->value,
+        ]);
+
+        return Provider::query()->create([
+            'user_id'      => $user->id,
+            'document'     => '06767310905',
+            'birth_date'   => '1990-01-01',
+            'recipient_id' => 're_'.uniqid(),
+        ]);
+    }
+
+    private function accountData(string $bankCode, string $branch, string $account): array
+    {
+        return [
+            'bank_code'           => $bankCode,
+            'type'                => 'checking',
+            'branch_number'       => $branch,
+            'branch_check_digit'  => '1',
+            'account_number'      => $account,
+            'account_check_digit' => '2',
+        ];
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function defaultBankAccountIdempotencyKeys(): array
+    {
+        return Http::recorded()
+            ->filter(fn ($pair) => str_contains($pair[0]->url(), 'default-bank-account'))
+            ->map(fn ($pair) => $pair[0]->header('Idempotency-Key')[0] ?? '')
+            ->values()
+            ->all();
+    }
+
+}