AgentWalletService.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentBalanceLog;
  5. use App\Models\Agent\AgentProfitCommissionProfile;
  6. use App\Models\Agent\AgentProfitEntry;
  7. use App\Models\Agent\AgentSetting;
  8. use Illuminate\Support\Facades\DB;
  9. /**
  10. * 代理资金唯一入口。
  11. *
  12. * 合营代理:总余额 balance = 保证金本金 margin_amount + 盈亏(可为负)。
  13. * - 充值全部计入保证金本金,不可提现;
  14. * - 盈亏为负时等于从保证金扣除,之后的盈利先补足保证金;
  15. * - 只有超出保证金本金的部分可以提现。
  16. * realtime_balance = balance + 尚未入账的实时分成,是判断「实时保证金」的内部字段,不对代理展示。
  17. */
  18. class AgentWalletService
  19. {
  20. /** 类型 => 方向(1 加,-1 减)。profit_share 单独走 creditProfitShare。 */
  21. private const DIRECTIONS = [
  22. 'credit' => 1, 'debit' => -1, 'recharge' => 1, 'withdraw_refund' => 1,
  23. 'equity_purchase' => -1, 'margin_credit' => 1, 'margin_refund' => -1,
  24. ];
  25. public const TYPE_NAMES = [
  26. 'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => 'USDT充值', 'withdraw_refund' => '提现退回',
  27. 'withdraw_freeze' => '提现冻结', 'equity_purchase' => '购买股份', 'margin_credit' => '人工增加保证金',
  28. 'margin_refund' => '退还保证金', 'profit_share' => '合营分成', 'commission' => '佣金(旧)',
  29. ];
  30. public function adjust(
  31. int $agentId,
  32. string $amount,
  33. string $type,
  34. string $remark,
  35. string $idempotencyKey,
  36. string $operatorType,
  37. ?int $operatorId,
  38. string $referenceType = '',
  39. ?int $referenceId = null
  40. ): AgentBalanceLog {
  41. if (!isset(self::DIRECTIONS[$type])) {
  42. throw new \InvalidArgumentException('资金变动类型错误');
  43. }
  44. if (bccomp($amount, '0', 4) <= 0) {
  45. throw new \InvalidArgumentException('金额必须大于0');
  46. }
  47. return DB::transaction(function () use ($agentId, $amount, $type, $remark, $idempotencyKey, $operatorType, $operatorId, $referenceType, $referenceId) {
  48. $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
  49. $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
  50. if ($existing) {
  51. $this->assertSameAdjustment($existing, $agentId, $type, $amount);
  52. return $existing;
  53. }
  54. $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
  55. $before = bcadd((string) $agent->balance, '0', 4);
  56. $after = bcadd($before, $signed, 4);
  57. $realtime = bcadd((string) $agent->realtime_balance, $signed, 4);
  58. if (self::DIRECTIONS[$type] < 0 && (bccomp($after, '0', 4) < 0 || bccomp($realtime, '0', 4) < 0)) {
  59. throw new \RuntimeException('代理余额不足');
  60. }
  61. $agent->balance = $after;
  62. $agent->realtime_balance = $realtime;
  63. if (($type === 'recharge' && $agent->isJoint()) || $type === 'margin_credit') {
  64. $agent->margin_amount = bcadd((string) $agent->margin_amount, $amount, 4);
  65. } elseif ($type === 'margin_refund') {
  66. if (bccomp((string) $agent->margin_amount, $amount, 4) < 0) {
  67. throw new \RuntimeException('退还金额不能超过保证金');
  68. }
  69. $agent->margin_amount = bcsub((string) $agent->margin_amount, $amount, 4);
  70. }
  71. $agent->save();
  72. $log = AgentBalanceLog::query()->create([
  73. 'agent_id' => $agentId, 'type' => $type, 'amount' => $signed,
  74. 'before_balance' => $before, 'after_balance' => $after,
  75. 'operator_type' => $operatorType, 'operator_id' => $operatorId,
  76. 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
  77. 'reference_type' => $referenceType, 'reference_id' => $referenceId,
  78. ]);
  79. $this->evaluateActivation($agent);
  80. return $log;
  81. });
  82. }
  83. /** 每日入账合营分成:实时余额在注单结算时已计入,这里只改 balance。调用方需已在事务内锁住代理。 */
  84. public function creditProfitShare(Agent $agent, string $signed, string $idempotencyKey, int $recordId, string $remark): ?AgentBalanceLog
  85. {
  86. if (bccomp($signed, '0', 4) === 0) return null;
  87. $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
  88. if ($existing) return $existing;
  89. $before = bcadd((string) $agent->balance, '0', 4);
  90. $agent->balance = bcadd($before, $signed, 4);
  91. $agent->save();
  92. return AgentBalanceLog::query()->create([
  93. 'agent_id' => $agent->id, 'type' => 'profit_share', 'amount' => bcadd($signed, '0', 4),
  94. 'before_balance' => $before, 'after_balance' => $agent->balance,
  95. 'operator_type' => 'system', 'operator_id' => null,
  96. 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
  97. 'reference_type' => 'agent_rebate_record', 'reference_id' => $recordId,
  98. ]);
  99. }
  100. /**
  101. * 合营分成开关。实时有效保证金 = min(保证金本金, 实时余额):
  102. * - 达到方案保证金 100% 才开启;
  103. * - 开启后低于方案保证金 × 停止比例(默认 10%)即关闭,之后的注单不再分成。
  104. */
  105. public function evaluateActivation(Agent $agent, bool $planChanged = false): bool
  106. {
  107. if (!$agent->isJoint()) {
  108. return $this->setActive($agent, false);
  109. }
  110. $profile = $agent->profit_commission_profile_id
  111. ? AgentProfitCommissionProfile::query()->find($agent->profit_commission_profile_id)
  112. : null;
  113. if (!$profile) {
  114. return $this->setActive($agent, false);
  115. }
  116. $effective = $this->effectiveMargin($agent);
  117. $required = bcadd((string) $profile->margin_amount, '0', 4);
  118. if (!(int) $agent->profit_active || $planChanged) {
  119. return $this->setActive($agent, bccomp($effective, $required, 4) >= 0);
  120. }
  121. $stop = bcdiv(bcmul($required, (string) AgentSetting::current()->margin_stop_percent, 8), '100', 4);
  122. return $this->setActive($agent, bccomp($effective, $stop, 4) >= 0);
  123. }
  124. public function effectiveMargin(Agent $agent): string
  125. {
  126. $margin = bcadd((string) $agent->margin_amount, '0', 4);
  127. $realtime = bcadd((string) $agent->realtime_balance, '0', 4);
  128. $value = bccomp($realtime, $margin, 4) < 0 ? $realtime : $margin;
  129. return bccomp($value, '0', 4) < 0 ? '0.0000' : $value;
  130. }
  131. /** 代理端可见的资金构成(不含实时余额)。 */
  132. public function funds(Agent $agent): array
  133. {
  134. $balance = bcadd((string) $agent->balance, '0', 4);
  135. $margin = bcadd((string) $agent->margin_amount, '0', 4);
  136. $data = [
  137. 'balance' => $balance,
  138. 'frozen_balance' => bcadd((string) $agent->frozen_balance, '0', 4),
  139. 'withdrawable_amount' => $this->withdrawable($agent),
  140. ];
  141. if ($agent->isJoint()) {
  142. $marginBalance = bccomp($balance, $margin, 4) < 0 ? $balance : $margin;
  143. $data += [
  144. 'margin_amount' => $margin,
  145. 'margin_balance' => bccomp($marginBalance, '0', 4) < 0 ? '0.0000' : $marginBalance,
  146. 'profit_amount' => bcsub($balance, $margin, 4),
  147. ];
  148. } else {
  149. $data['equity_percent'] = bcadd((string) $agent->equity_percent, '0', 4);
  150. }
  151. return $data;
  152. }
  153. /**
  154. * 可提现金额。合营:总余额加上「待入账亏损的最坏情况」后,超出保证金本金的部分;入股:全部余额。
  155. * 待入账分成按结算日依次入账,取前缀和的最小值,保证每次入账后余额都不会低于保证金以下的已提部分。
  156. */
  157. public function withdrawable(Agent $agent): string
  158. {
  159. $balance = bcadd((string) $agent->balance, '0', 4);
  160. if (!$agent->isJoint()) {
  161. return bccomp($balance, '0', 4) > 0 ? $balance : '0.0000';
  162. }
  163. $sums = AgentProfitEntry::query()->where('agent_id', $agent->id)
  164. ->where('status', AgentProfitEntry::STATUS_COUNTED)->whereNull('rebate_record_id')
  165. ->groupBy('settle_date')->orderBy('settle_date')
  166. ->selectRaw('settle_date, COALESCE(SUM(share_amount),0) total')->pluck('total');
  167. $running = '0.0000';
  168. $floor = '0.0000';
  169. foreach ($sums as $sum) {
  170. $running = bcadd($running, (string) $sum, 4);
  171. if (bccomp($running, $floor, 4) < 0) $floor = $running;
  172. }
  173. $available = bcsub(bcadd($balance, $floor, 4), (string) $agent->margin_amount, 4);
  174. return bccomp($available, '0', 4) > 0 ? $available : '0.0000';
  175. }
  176. private function setActive(Agent $agent, bool $active): bool
  177. {
  178. if ((int) $agent->profit_active !== ($active ? 1 : 0)) {
  179. $agent->profit_active = $active ? 1 : 0;
  180. $agent->profit_active_changed_at = now();
  181. $agent->save();
  182. }
  183. return $active;
  184. }
  185. private function assertSameAdjustment(AgentBalanceLog $log, int $agentId, string $type, string $amount): void
  186. {
  187. $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
  188. if ((int) $log->agent_id !== $agentId || $log->type !== $type || bccomp((string) $log->amount, $signed, 4) !== 0) {
  189. throw new \RuntimeException('request_id 已用于其他资金调整');
  190. }
  191. }
  192. }