ProviderWithdrawalService.php 11 KB

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