ProviderWithdrawalService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. <?php
  2. namespace App\Services;
  3. use App\Enums\PaymentStatusEnum;
  4. use App\Enums\ProviderWithdrawalStatusEnum;
  5. use App\Models\PaymentSplit;
  6. use App\Models\Provider;
  7. use App\Models\ProviderWithdrawal;
  8. use App\Services\Pagarme\PagarmeTransferService;
  9. use Carbon\Carbon;
  10. use Illuminate\Database\Eloquent\Collection;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Log;
  13. class ProviderWithdrawalService
  14. {
  15. public function __construct(
  16. private readonly PagarmeTransferService $pagarmeTransfer,
  17. private readonly ProviderBankAccountService $providerBankAccounts,
  18. ) {}
  19. public function handleTransferWebhook(array $data): void
  20. {
  21. $transferId = data_get($data, 'id');
  22. if (empty($transferId)) {
  23. return;
  24. }
  25. $withdrawal = ProviderWithdrawal::query()
  26. ->where('transfer_id', $transferId)
  27. ->first();
  28. if (! $withdrawal) {
  29. Log::channel('pagarme')->warning('Saque do prestador não encontrado para o webhook de transferência', [
  30. 'transfer_id' => $transferId,
  31. ]);
  32. return;
  33. }
  34. $status = data_get($data, 'status');
  35. if ($status === ProviderWithdrawalStatusEnum::TRANSFERRED->value) {
  36. $withdrawal->forceFill([
  37. 'status' => ProviderWithdrawalStatusEnum::TRANSFERRED,
  38. 'completed_at' => now(),
  39. ])->save();
  40. } elseif (in_array($status, [ProviderWithdrawalStatusEnum::FAILED->value, ProviderWithdrawalStatusEnum::CANCELED->value], true)) {
  41. $withdrawal->paymentSplits()
  42. ->update(['provider_withdrawal_id' => null]);
  43. $withdrawal->forceFill([
  44. 'status' => ProviderWithdrawalStatusEnum::fromString($status),
  45. 'failed_at' => $status === ProviderWithdrawalStatusEnum::FAILED->value ? now() : null,
  46. 'bank_response' => $status === ProviderWithdrawalStatusEnum::FAILED->value ? (data_get($data, 'bank_response')) : null,
  47. ])->save();
  48. } elseif ($status === ProviderWithdrawalStatusEnum::PROCESSING->value) {
  49. $withdrawal->forceFill([
  50. 'status' => ProviderWithdrawalStatusEnum::PROCESSING,
  51. ])->save();
  52. }
  53. }
  54. //
  55. public function getAvailableBalance(Provider $provider): float
  56. {
  57. $earnings = (float) PaymentSplit::query()
  58. ->where('provider_id', $provider->id)
  59. ->whereHas('payment', fn ($q) => $this->availablePaymentQuery($q))
  60. ->sum('gross_amount');
  61. $withdrawn = (float) ProviderWithdrawal::query()
  62. ->where('provider_id', $provider->id)
  63. ->whereIn('status', [
  64. ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
  65. ProviderWithdrawalStatusEnum::PROCESSING,
  66. ProviderWithdrawalStatusEnum::TRANSFERRED,
  67. ])
  68. ->sum('gross_amount');
  69. return max(0, $earnings - $withdrawn);
  70. }
  71. public function getPendingBalance(Provider $provider): float
  72. {
  73. return (float) PaymentSplit::query()
  74. ->where('provider_id', $provider->id)
  75. ->whereHas('payment', fn ($q) => $this->pendingPaymentQuery($q))
  76. ->sum('gross_amount');
  77. }
  78. //
  79. public function getPaymentSplits(Provider $provider, ?string $paymentStatus = null): Collection
  80. {
  81. return PaymentSplit::query()
  82. ->where('provider_id', $provider->id)
  83. ->when(
  84. $paymentStatus,
  85. fn ($query) => $query->whereHas('payment', fn ($q) => $q->where('status', $paymentStatus))
  86. )
  87. ->with([
  88. 'payment.schedule.client.user',
  89. 'payment.schedule.client.profileMedia',
  90. 'payment.servicePackage.items.schedule.client.user',
  91. 'payment.servicePackage.items.schedule.client.profileMedia',
  92. ])
  93. ->orderBy('created_at', 'desc')
  94. ->get();
  95. }
  96. //
  97. public function requestWithdrawal(Provider $provider): ProviderWithdrawal
  98. {
  99. if (empty($provider->recipient_id)) {
  100. throw new \RuntimeException(__('messages.provider_requires_pagarme_recipient'));
  101. }
  102. $primaryAccount = $this->providerBankAccounts->ensureGatewayMatchesPrimary($provider);
  103. return DB::transaction(function () use ($provider, $primaryAccount) {
  104. Provider::query()
  105. ->where('id', $provider->id)
  106. ->lockForUpdate()
  107. ->first();
  108. $pending = ProviderWithdrawal::query()
  109. ->where('provider_id', $provider->id)
  110. ->whereIn('status', [
  111. ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
  112. ProviderWithdrawalStatusEnum::PROCESSING,
  113. ])
  114. ->exists();
  115. if ($pending) {
  116. throw new \RuntimeException(__('messages.withdrawal_already_in_progress'));
  117. }
  118. $available = $this->getAvailableBalance($provider);
  119. if ($available <= 0) {
  120. throw new \RuntimeException(__('messages.insufficient_withdrawal_balance'));
  121. }
  122. $paymentTransferFeeAmount = $this->paymentTransferFeeAmount();
  123. $netAmount = max(0, round($available - $paymentTransferFeeAmount, 2));
  124. if ($netAmount <= 0) {
  125. throw new \RuntimeException(__('messages.insufficient_balance_for_transfer_fee'));
  126. }
  127. $amountInCents = (int) round($netAmount * 100);
  128. $idempotencyKey = sprintf('withdrawal-%d-%s', $provider->id, now()->timestamp);
  129. $bankAccount = $primaryAccount->toGatewayBankAccount();
  130. $withdrawal = ProviderWithdrawal::create([
  131. 'provider_id' => $provider->id,
  132. 'recipient_id' => $provider->recipient_id,
  133. 'idempotency_key' => $idempotencyKey,
  134. 'gross_amount' => $available,
  135. 'gateway_fee_amount' => $paymentTransferFeeAmount,
  136. 'net_amount' => $netAmount,
  137. 'status' => ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
  138. 'bank_account' => $bankAccount,
  139. 'metadata' => [
  140. 'provider_id' => $provider->id,
  141. 'provider_bank_account_id' => $primaryAccount->id,
  142. 'gross_amount' => $available,
  143. 'payment_transfer_fee_amount' => $paymentTransferFeeAmount,
  144. 'net_amount' => $netAmount,
  145. ],
  146. ]);
  147. try {
  148. $transfer = $this->pagarmeTransfer->createTransfer(
  149. $amountInCents,
  150. $provider->recipient_id,
  151. $idempotencyKey,
  152. );
  153. $withdrawal->forceFill([
  154. 'transfer_id' => $transfer->id,
  155. 'status' => $transfer->status ?? ProviderWithdrawalStatusEnum::PROCESSING->value,
  156. 'type' => $transfer->type,
  157. 'gateway_payload' => $transfer->toArray(),
  158. ])->save();
  159. PaymentSplit::query()
  160. ->where('provider_id', $provider->id)
  161. ->whereNull('provider_withdrawal_id')
  162. ->whereHas('payment', fn ($q) => $this->availablePaymentQuery($q))
  163. ->update(['provider_withdrawal_id' => $withdrawal->id]);
  164. return $withdrawal->fresh();
  165. } catch (\Throwable $e) {
  166. $withdrawal->forceFill([
  167. 'status' => ProviderWithdrawalStatusEnum::FAILED,
  168. 'failed_at' => now(),
  169. 'bank_response' => $e->getMessage(),
  170. ])->save();
  171. throw $e;
  172. }
  173. });
  174. }
  175. //
  176. public function getWithdrawals(Provider $provider): Collection
  177. {
  178. return ProviderWithdrawal::query()
  179. ->where('provider_id', $provider->id)
  180. ->orderBy('created_at', 'desc')
  181. ->get();
  182. }
  183. public function getAllWithdrawals(): Collection
  184. {
  185. return ProviderWithdrawal::query()
  186. ->with(['provider.user', 'paymentSplits'])
  187. ->orderBy('created_at', 'desc')
  188. ->get();
  189. }
  190. public function getWithdrawal(int $id, Provider $provider): ProviderWithdrawal
  191. {
  192. return ProviderWithdrawal::query()
  193. ->where('id', $id)
  194. ->where('provider_id', $provider->id)
  195. ->firstOrFail();
  196. }
  197. //
  198. public function getWithdrawalFees(): array
  199. {
  200. return [
  201. 'payment_transfer_fee_amount' => $this->paymentTransferFeeAmount(),
  202. ];
  203. }
  204. private function paymentTransferFeeAmount(): float
  205. {
  206. return round((float) config('services.pagarme.transfer_fee_amount'), 2);
  207. }
  208. //
  209. private function availableScheduleQuery($query)
  210. {
  211. return $query
  212. ->where('code_verified', true)
  213. ->when(
  214. ! app()->environment('local', 'development'),
  215. fn ($query) => $query->whereRaw(
  216. '(date + end_time) <= ?',
  217. [$this->withdrawalReleaseCutoff()]
  218. )
  219. );
  220. }
  221. private function unavailableScheduleQuery($query)
  222. {
  223. return $query->where(function ($query) {
  224. $query->where('code_verified', false)
  225. ->when(
  226. ! app()->environment('local', 'development'),
  227. fn ($query) => $query->orWhereRaw(
  228. '(date + end_time) > ?',
  229. [$this->withdrawalReleaseCutoff()]
  230. )
  231. );
  232. });
  233. }
  234. private function availablePaymentQuery($query)
  235. {
  236. return $query
  237. ->where('status', PaymentStatusEnum::PAID)
  238. ->where(function ($query) {
  239. $query->whereHas('schedule', fn ($query) => $this->availableScheduleQuery($query))
  240. ->orWhere(function ($query) {
  241. $query->whereNotNull('service_package_id')
  242. ->whereHas('servicePackage.items')
  243. ->whereDoesntHave('servicePackage.items.schedule', fn ($query) => $this->unavailableScheduleQuery($query));
  244. });
  245. });
  246. }
  247. private function pendingPaymentQuery($query)
  248. {
  249. return $query
  250. ->where('status', PaymentStatusEnum::PAID)
  251. ->where(function ($query) {
  252. $query->whereHas('schedule', fn ($query) => $this->pendingScheduleQuery($query))
  253. ->orWhereHas('servicePackage.items.schedule', fn ($query) => $this->pendingScheduleQuery($query));
  254. });
  255. }
  256. private function pendingScheduleQuery($query)
  257. {
  258. return $query
  259. ->whereNotIn('status', ['cancelled', 'rejected'])
  260. ->where(function ($q) {
  261. $q->where('code_verified', false)
  262. ->orWhereRaw(
  263. '(date + end_time) > ?',
  264. [$this->withdrawalReleaseCutoff()]
  265. );
  266. });
  267. }
  268. private function withdrawalReleaseCutoff(): string
  269. {
  270. $releaseDays = max(0, (int) config('services.pagarme.withdrawal_release_days'));
  271. return Carbon::now()->subDays($releaseDays)->format('Y-m-d H:i:s');
  272. }
  273. }