ProviderWithdrawalService.php 10 KB

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