ProviderWithdrawalService.php 9.7 KB

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