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) ?? ''; } }