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