ProviderWithdrawalService.php 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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 getAvailableBalance(Provider $provider): float
  19. {
  20. $earnings = (float) PaymentSplit::query()
  21. ->where('provider_id', $provider->id)
  22. ->whereHas('payment', fn ($q) => $q->where('status', PaymentStatusEnum::PAID))
  23. ->whereHas('payment.schedule', fn ($q) => $this->availableScheduleQuery($q))
  24. ->sum('gross_amount');
  25. $withdrawn = (float) ProviderWithdrawal::query()
  26. ->where('provider_id', $provider->id)
  27. ->whereIn('status', [
  28. ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
  29. ProviderWithdrawalStatusEnum::PROCESSING,
  30. ProviderWithdrawalStatusEnum::TRANSFERRED,
  31. ])
  32. ->sum('gross_amount');
  33. return max(0, $earnings - $withdrawn);
  34. }
  35. public function getPendingBalance(Provider $provider): float
  36. {
  37. return (float) PaymentSplit::query()
  38. ->where('provider_id', $provider->id)
  39. ->whereHas('payment', fn ($q) => $q->where('status', PaymentStatusEnum::PAID))
  40. ->whereHas('payment.schedule', fn ($q) => $this->pendingScheduleQuery($q))
  41. ->sum('gross_amount');
  42. }
  43. public function getPaymentSplits(Provider $provider): Collection
  44. {
  45. return PaymentSplit::query()
  46. ->where('provider_id', $provider->id)
  47. ->with(['payment.schedule.client.user'])
  48. ->orderBy('created_at', 'desc')
  49. ->get();
  50. }
  51. public function requestWithdrawal(Provider $provider): ProviderWithdrawal
  52. {
  53. if (empty($provider->recipient_id)) {
  54. throw new \RuntimeException('Prestador nao possui recipient_id no Pagar.me.');
  55. }
  56. return DB::transaction(function () use ($provider) {
  57. Provider::query()
  58. ->where('id', $provider->id)
  59. ->lockForUpdate()
  60. ->first();
  61. $pending = ProviderWithdrawal::query()
  62. ->where('provider_id', $provider->id)
  63. ->whereIn('status', [
  64. ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
  65. ProviderWithdrawalStatusEnum::PROCESSING,
  66. ])
  67. ->exists();
  68. if ($pending) {
  69. throw new \RuntimeException('Voce ja possui um saque em andamento.');
  70. }
  71. $available = $this->getAvailableBalance($provider);
  72. if ($available <= 0) {
  73. throw new \RuntimeException('Saldo disponivel insuficiente para saque.');
  74. }
  75. $amountInCents = (int) round($available * 100);
  76. $idempotencyKey = sprintf('withdrawal-%d-%s', $provider->id, now()->timestamp);
  77. $bankAccount = $provider->recipient_default_bank_account;
  78. $withdrawal = ProviderWithdrawal::create([
  79. 'provider_id' => $provider->id,
  80. 'recipient_id' => $provider->recipient_id,
  81. 'idempotency_key' => $idempotencyKey,
  82. 'gross_amount' => $available,
  83. 'net_amount' => $available,
  84. 'status' => ProviderWithdrawalStatusEnum::PENDING_TRANSFER,
  85. 'bank_account' => $bankAccount,
  86. 'metadata' => [
  87. 'provider_id' => $provider->id,
  88. 'amount' => $available,
  89. ],
  90. ]);
  91. try {
  92. $transfer = $this->pagarmeTransfer->createTransfer(
  93. $amountInCents,
  94. $provider->recipient_id,
  95. $idempotencyKey,
  96. );
  97. $withdrawal->forceFill([
  98. 'transfer_id' => $transfer->id,
  99. 'status' => $transfer->status ?? ProviderWithdrawalStatusEnum::PROCESSING->value,
  100. 'type' => $transfer->type,
  101. 'gateway_fee_amount' => $transfer->fee ?? 0,
  102. 'net_amount' => max(0, $available - ($transfer->fee ?? 0) / 100),
  103. 'gateway_payload' => $transfer->toArray(),
  104. ])->save();
  105. PaymentSplit::query()
  106. ->where('provider_id', $provider->id)
  107. ->whereNull('provider_withdrawal_id')
  108. ->whereHas('payment', fn ($q) => $q->where('status', PaymentStatusEnum::PAID))
  109. ->whereHas('payment.schedule', fn ($q) => $this->availableScheduleQuery($q))
  110. ->update(['provider_withdrawal_id' => $withdrawal->id]);
  111. return $withdrawal->fresh();
  112. } catch (\Throwable $e) {
  113. $withdrawal->forceFill([
  114. 'status' => ProviderWithdrawalStatusEnum::FAILED,
  115. 'failed_at' => now(),
  116. 'bank_response' => $e->getMessage(),
  117. ])->save();
  118. throw $e;
  119. }
  120. });
  121. }
  122. public function getWithdrawals(Provider $provider): Collection
  123. {
  124. return ProviderWithdrawal::query()
  125. ->where('provider_id', $provider->id)
  126. ->orderBy('created_at', 'desc')
  127. ->get();
  128. }
  129. public function getAllWithdrawals(): Collection
  130. {
  131. return ProviderWithdrawal::query()
  132. ->with(['provider.user', 'paymentSplits'])
  133. ->orderBy('created_at', 'desc')
  134. ->get();
  135. }
  136. public function getWithdrawal(int $id, Provider $provider): ProviderWithdrawal
  137. {
  138. return ProviderWithdrawal::query()
  139. ->where('id', $id)
  140. ->where('provider_id', $provider->id)
  141. ->firstOrFail();
  142. }
  143. public function handleTransferWebhook(array $data): void
  144. {
  145. $transferId = $data['id'] ?? null;
  146. if (empty($transferId)) {
  147. return;
  148. }
  149. $withdrawal = ProviderWithdrawal::query()
  150. ->where('transfer_id', $transferId)
  151. ->first();
  152. if (! $withdrawal) {
  153. Log::channel('pagarme')->warning('ProviderWithdrawal not found for transfer webhook', [
  154. 'transfer_id' => $transferId,
  155. ]);
  156. return;
  157. }
  158. $status = $data['status'] ?? null;
  159. if ($status === ProviderWithdrawalStatusEnum::TRANSFERRED->value) {
  160. $withdrawal->forceFill([
  161. 'status' => ProviderWithdrawalStatusEnum::TRANSFERRED,
  162. 'completed_at' => now(),
  163. ])->save();
  164. } elseif (in_array($status, [ProviderWithdrawalStatusEnum::FAILED->value, ProviderWithdrawalStatusEnum::CANCELED->value], true)) {
  165. $withdrawal->paymentSplits()
  166. ->update(['provider_withdrawal_id' => null]);
  167. $withdrawal->forceFill([
  168. 'status' => ProviderWithdrawalStatusEnum::fromString($status),
  169. 'failed_at' => $status === ProviderWithdrawalStatusEnum::FAILED->value ? now() : null,
  170. 'bank_response' => $status === ProviderWithdrawalStatusEnum::FAILED->value ? ($data['bank_response'] ?? null) : null,
  171. ])->save();
  172. } elseif ($status === ProviderWithdrawalStatusEnum::PROCESSING->value) {
  173. $withdrawal->forceFill([
  174. 'status' => ProviderWithdrawalStatusEnum::PROCESSING,
  175. ])->save();
  176. }
  177. }
  178. private function availableScheduleQuery($query)
  179. {
  180. return $query
  181. ->where('code_verified', true)
  182. ->when(
  183. ! app()->environment('local', 'development'),
  184. fn ($query) => $query->whereRaw(
  185. $this->scheduleEndedAtExpression().' <= ?',
  186. [$this->withdrawalReleaseCutoff()]
  187. )
  188. );
  189. }
  190. private function pendingScheduleQuery($query)
  191. {
  192. return $query
  193. ->whereNotIn('status', ['cancelled', 'rejected'])
  194. ->where(function ($q) {
  195. $q->where('code_verified', false)
  196. ->orWhereRaw(
  197. $this->scheduleEndedAtExpression().' > ?',
  198. [$this->withdrawalReleaseCutoff()]
  199. );
  200. });
  201. }
  202. private function scheduleEndedAtExpression(): string
  203. {
  204. return match (DB::connection()->getDriverName()) {
  205. 'pgsql' => '(date + end_time)',
  206. 'sqlite' => "datetime(date || ' ' || end_time)",
  207. default => 'TIMESTAMP(date, end_time)',
  208. };
  209. }
  210. private function withdrawalReleaseCutoff(): string
  211. {
  212. return Carbon::now()->subDays(5)->format('Y-m-d H:i:s');
  213. }
  214. }