AgentWalletService.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentBalanceLog;
  5. use App\Models\Agent\AgentManualCreditLock;
  6. use App\Models\Agent\AgentProfitCommissionProfile;
  7. use App\Models\Agent\AgentProfitEntry;
  8. use App\Models\Agent\AgentRechargeLock;
  9. use App\Models\Agent\AgentSetting;
  10. use DateTimeInterface;
  11. use Illuminate\Support\Facades\DB;
  12. /**
  13. * 代理资金唯一入口。
  14. *
  15. * 合营代理:总余额 balance = 保证金本金 margin_amount + 盈亏(可为负)。
  16. * - 充值全部计入保证金本金,不可提现;
  17. * - 盈亏为负时等于从保证金扣除,之后的盈利先补足保证金;
  18. * - 只有超出保证金本金的部分可以提现。
  19. * realtime_balance = balance + 尚未入账的实时分成,是判断「实时保证金」的内部字段,不对代理展示。
  20. *
  21. * 入股代理:充值计入余额,可以立即购买股份;但充值后 N 天(入股配置 equity_recharge_lock_days,默认 7)内不能提现,
  22. * 记录在 agent_recharge_locks。购买股份优先消耗锁定中的充值。可提现 = 余额 − 锁定中的金额。
  23. */
  24. class AgentWalletService
  25. {
  26. /** 类型 => 方向(1 加,-1 减)。profit_share 单独走 creditProfitShare。 */
  27. private const DIRECTIONS = [
  28. 'credit' => 1, 'debit' => -1, 'recharge' => 1, 'withdraw_refund' => 1,
  29. 'equity_purchase' => -1, 'margin_credit' => 1, 'margin_refund' => -1,
  30. ];
  31. private array $lookups = [];
  32. public const TYPE_NAMES = [
  33. 'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => '充值', 'withdraw_refund' => '提现退回',
  34. 'withdraw_freeze' => '提现冻结', 'equity_purchase' => '购买股份', 'margin_credit' => '人工增加保证金',
  35. 'margin_refund' => '退还保证金', 'profit_share' => '合营分成', 'commission' => '佣金(旧)',
  36. 'credit_unlock' => '人工加款解锁',
  37. ];
  38. public function adjust(
  39. int $agentId,
  40. string $amount,
  41. string $type,
  42. string $remark,
  43. string $idempotencyKey,
  44. string $operatorType,
  45. ?int $operatorId,
  46. string $referenceType = '',
  47. ?int $referenceId = null,
  48. bool $freezeEquityCredit = false,
  49. ): AgentBalanceLog {
  50. if (!isset(self::DIRECTIONS[$type])) {
  51. throw new \InvalidArgumentException('资金变动类型错误');
  52. }
  53. if (bccomp($amount, '0', 4) <= 0) {
  54. throw new \InvalidArgumentException('金额必须大于0');
  55. }
  56. return DB::transaction(function () use ($agentId, $amount, $type, $remark, $idempotencyKey, $operatorType, $operatorId, $referenceType, $referenceId, $freezeEquityCredit) {
  57. $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
  58. $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
  59. if ($existing) {
  60. $this->assertSameAdjustment($existing, $agentId, $type, $amount);
  61. return $existing;
  62. }
  63. $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
  64. $freezeCredit = $freezeEquityCredit && $type === 'credit' && $agent->isEquity();
  65. $purchaseFromFrozen = $type === 'equity_purchase' ? $this->consumeManualCredits($agent, $amount) : '0.0000';
  66. $balanceChange = $freezeCredit ? '0.0000' : bcadd($signed, $purchaseFromFrozen, 4);
  67. $before = bcadd((string) $agent->balance, '0', 4);
  68. $after = bcadd($before, $balanceChange, 4);
  69. $realtime = bcadd((string) $agent->realtime_balance, $balanceChange, 4);
  70. // 扣款后,待入账分成按结算日依次入账时余额也不能为负(同时保证实时余额不为负)。
  71. if (self::DIRECTIONS[$type] < 0 && bccomp(bcadd($after, $this->pendingFloor($agent), 4), '0', 4) < 0) {
  72. throw new \RuntimeException('代理余额不足');
  73. }
  74. $agent->balance = $after;
  75. $agent->realtime_balance = $realtime;
  76. if ($freezeCredit) {
  77. $lock = AgentManualCreditLock::query()->create([
  78. 'agent_id' => $agentId, 'amount' => $signed, 'remaining' => $signed,
  79. 'unlock_at' => now()->addDays(AgentManualCreditLock::LOCK_DAYS),
  80. ]);
  81. $agent->frozen_balance = bcadd((string) $agent->frozen_balance, $signed, 4);
  82. $referenceType = AgentManualCreditLock::REFERENCE_TYPE;
  83. $referenceId = (int) $lock->id;
  84. }
  85. if (($type === 'recharge' && $agent->isJoint()) || $type === 'margin_credit') {
  86. $agent->margin_amount = bcadd((string) $agent->margin_amount, $amount, 4);
  87. } elseif ($type === 'margin_refund') {
  88. if (bccomp((string) $agent->margin_amount, $amount, 4) < 0) {
  89. throw new \RuntimeException('退还金额不能超过保证金');
  90. }
  91. $agent->margin_amount = bcsub((string) $agent->margin_amount, $amount, 4);
  92. }
  93. $agent->save();
  94. $log = AgentBalanceLog::query()->create([
  95. 'agent_id' => $agentId, 'type' => $type, 'amount' => $signed,
  96. 'before_balance' => $before, 'after_balance' => $after,
  97. 'operator_type' => $operatorType, 'operator_id' => $operatorId,
  98. 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
  99. 'reference_type' => $referenceType, 'reference_id' => $referenceId,
  100. ]);
  101. if ($type === 'recharge' && $agent->isEquity()) {
  102. $this->lockRecharge($agent, $log, $amount, $referenceType, $referenceId);
  103. } elseif ($type === 'equity_purchase') {
  104. $this->consumeLocks($agentId, bcsub($amount, $purchaseFromFrozen, 4));
  105. } elseif ($agent->isEquity() && ($type === 'debit' || ($type === 'credit' && !$freezeCredit))) {
  106. // 人工扣款先用可提现金额。加款前按旧余额清理历史超额锁定,避免锁住新到账的钱。
  107. $this->capRechargeLocks($agent, $type === 'debit' ? $after : $before, now());
  108. }
  109. $this->evaluateActivation($agent);
  110. return $log;
  111. });
  112. }
  113. private function manualCreditBalance(Agent $agent): string
  114. {
  115. if (!$agent->isEquity()) return '0.0000';
  116. return bcadd((string) AgentManualCreditLock::query()->pending()->where('agent_id', $agent->id)->sum('remaining'), '0', 4);
  117. }
  118. public function purchaseBalance(Agent $agent): string
  119. {
  120. return bcadd((string) $agent->balance, $this->manualCreditBalance($agent), 4);
  121. }
  122. public function withdrawalFrozenBalance(Agent $agent): string
  123. {
  124. return bcsub((string) $agent->frozen_balance, $this->manualCreditBalance($agent), 4);
  125. }
  126. /** 购股先扣人工加款冻结额,再使用余额中的充值锁定额和可提现金额。调用方已锁住代理。 */
  127. private function consumeManualCredits(Agent $agent, string $amount): string
  128. {
  129. $left = $amount;
  130. $locks = AgentManualCreditLock::query()->pending()->where('agent_id', $agent->id)
  131. ->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
  132. if ($locks->isEmpty()) return '0.0000';
  133. if (bccomp($this->withdrawalFrozenBalance($agent), '0', 4) < 0) {
  134. throw new \RuntimeException('代理冻结余额异常');
  135. }
  136. foreach ($locks as $lock) {
  137. if (bccomp($left, '0', 4) <= 0) break;
  138. $take = bccomp($lock->remaining, $left, 4) < 0 ? $lock->remaining : $left;
  139. $lock->remaining = bcsub($lock->remaining, $take, 4);
  140. $lock->save();
  141. $left = bcsub($left, $take, 4);
  142. }
  143. $spent = bcsub($amount, $left, 4);
  144. $agent->frozen_balance = bcsub((string) $agent->frozen_balance, $spent, 4);
  145. return $spent;
  146. }
  147. public function releaseManualCredits(int $agentId, DateTimeInterface $at): int
  148. {
  149. return DB::transaction(function () use ($agentId, $at) {
  150. $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
  151. $due = AgentManualCreditLock::query()->pending()->where('agent_id', $agentId)
  152. ->where('unlock_at', '<=', $at)->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
  153. if ($due->isEmpty()) return 0;
  154. if (bccomp($this->withdrawalFrozenBalance($agent), '0', 4) < 0) {
  155. throw new \RuntimeException('代理冻结余额异常');
  156. }
  157. $this->capRechargeLocks($agent, (string) $agent->balance, $at);
  158. foreach ($due as $lock) {
  159. $amount = $lock->remaining;
  160. $before = $agent->balance;
  161. $agent->balance = bcadd($before, $amount, 4);
  162. $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $amount, 4);
  163. $agent->frozen_balance = bcsub((string) $agent->frozen_balance, $amount, 4);
  164. AgentBalanceLog::query()->create([
  165. 'agent_id' => $agentId, 'type' => 'credit_unlock', 'amount' => $amount,
  166. 'before_balance' => $before, 'after_balance' => $agent->balance,
  167. 'operator_type' => 'system', 'idempotency_key' => 'agent-credit-unlock:' . $lock->id,
  168. 'reference_type' => AgentManualCreditLock::REFERENCE_TYPE, 'reference_id' => $lock->id,
  169. 'remark' => '人工加款冻结期满',
  170. ]);
  171. $lock->remaining = '0.0000';
  172. $lock->released_at = $at;
  173. $lock->save();
  174. }
  175. $agent->save();
  176. return $due->count();
  177. }, 3);
  178. }
  179. private function capRechargeLocks(Agent $agent, string $balance, DateTimeInterface $at): void
  180. {
  181. $excess = bcsub($this->lockedAmount($agent, $at), $balance, 4);
  182. if (bccomp($excess, '0', 4) > 0) {
  183. $this->consumeLocks((int) $agent->id, $excess, $at);
  184. }
  185. }
  186. /** 入股代理的充值锁定 N 天。调用方已在事务内锁住代理。 */
  187. private function lockRecharge(Agent $agent, AgentBalanceLog $log, string $amount, string $referenceType, ?int $referenceId): void
  188. {
  189. $days = (int) AgentSetting::current()->equity_recharge_lock_days;
  190. if ($days <= 0) return;
  191. AgentRechargeLock::query()->create([
  192. 'agent_id' => $agent->id, 'balance_log_id' => $log->id,
  193. 'reference_type' => $referenceType, 'reference_id' => $referenceId,
  194. 'amount' => $amount, 'remaining' => $amount, 'unlock_at' => now()->addDays($days),
  195. ]);
  196. }
  197. /** 扣减仍在锁定期的充值,先到期的先扣。 */
  198. private function consumeLocks(int $agentId, string $amount, ?DateTimeInterface $at = null): void
  199. {
  200. $left = bcadd($amount, '0', 4);
  201. if (bccomp($left, '0', 4) <= 0) return;
  202. $locks = AgentRechargeLock::query()->where('agent_id', $agentId)->where('unlock_at', '>', $at ?? now())
  203. ->where('remaining', '>', 0)->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
  204. foreach ($locks as $lock) {
  205. if (bccomp($left, '0', 4) <= 0) break;
  206. $take = bccomp((string) $lock->remaining, $left, 4) < 0 ? bcadd((string) $lock->remaining, '0', 4) : $left;
  207. $lock->remaining = bcsub((string) $lock->remaining, $take, 4);
  208. $lock->save();
  209. $left = bcsub($left, $take, 4);
  210. }
  211. }
  212. /** 入股代理仍在锁定期内的充值金额。 */
  213. public function lockedAmount(Agent $agent, ?DateTimeInterface $at = null): string
  214. {
  215. if (!$agent->isEquity()) return '0.0000';
  216. $sum = AgentRechargeLock::query()->where('agent_id', $agent->id)->where('unlock_at', '>', $at ?? now())->sum('remaining');
  217. return bcadd((string) $sum, '0', 4);
  218. }
  219. /** 入股代理锁定中的充值明细(按到期时间),给资金页展示「X 月 X 日后可提现」。 */
  220. public function activeLocks(Agent $agent): array
  221. {
  222. if (!$agent->isEquity()) return [];
  223. return AgentRechargeLock::query()->where('agent_id', $agent->id)->where('unlock_at', '>', now())
  224. ->where('remaining', '>', 0)->orderBy('unlock_at')->limit(100)
  225. ->get(['id', 'amount', 'remaining', 'unlock_at', 'created_at'])
  226. ->map(fn (AgentRechargeLock $lock) => [
  227. 'amount' => bcadd((string) $lock->amount, '0', 4), 'remaining' => bcadd((string) $lock->remaining, '0', 4),
  228. 'recharged_at' => $lock->created_at ? \Carbon\Carbon::parse($lock->created_at)->format('Y-m-d H:i:s') : null,
  229. 'unlock_at' => \Carbon\Carbon::parse($lock->unlock_at)->format('Y-m-d H:i:s'),
  230. ])->all();
  231. }
  232. /** 每日入账合营分成:实时余额在注单结算时已计入,这里只改 balance。调用方需已在事务内锁住代理。 */
  233. public function creditProfitShare(Agent $agent, string $signed, string $idempotencyKey, int $recordId, string $remark): ?AgentBalanceLog
  234. {
  235. if (bccomp($signed, '0', 4) === 0) return null;
  236. $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
  237. if ($existing) return $existing;
  238. $before = bcadd((string) $agent->balance, '0', 4);
  239. $agent->balance = bcadd($before, $signed, 4);
  240. $agent->save();
  241. return AgentBalanceLog::query()->create([
  242. 'agent_id' => $agent->id, 'type' => 'profit_share', 'amount' => bcadd($signed, '0', 4),
  243. 'before_balance' => $before, 'after_balance' => $agent->balance,
  244. 'operator_type' => 'system', 'operator_id' => null,
  245. 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
  246. 'reference_type' => 'agent_rebate_record', 'reference_id' => $recordId,
  247. ]);
  248. }
  249. /**
  250. * 合营分成开关。实时有效保证金 = min(保证金本金, 实时余额):
  251. * - 达到方案保证金 100% 才开启;
  252. * - 开启后低于方案保证金 × 停止比例(默认 10%)即关闭,之后的注单不再分成。
  253. */
  254. public function evaluateActivation(Agent $agent): bool
  255. {
  256. if (!$agent->isJoint()) {
  257. return $this->setActive($agent, false);
  258. }
  259. $required = $agent->profit_commission_profile_id ? $this->requiredMargin((int) $agent->profit_commission_profile_id) : null;
  260. if ($required === null) {
  261. return $this->setActive($agent, false);
  262. }
  263. $effective = $this->effectiveMargin($agent);
  264. if (!(int) $agent->profit_active) {
  265. return $this->setActive($agent, bccomp($effective, $required, 4) >= 0);
  266. }
  267. $stop = bcdiv(bcmul($required, $this->lookup('stop', fn () => (string) AgentSetting::current()->margin_stop_percent), 8), '100', 4);
  268. return $this->setActive($agent, bccomp($effective, $stop, 4) >= 0);
  269. }
  270. /** 方案要求的保证金;方案不存在返回 null。 */
  271. private function requiredMargin(int $profileId): ?string
  272. {
  273. $margin = $this->lookup('profile:' . $profileId, function () use ($profileId) {
  274. $value = AgentProfitCommissionProfile::query()->whereKey($profileId)->value('margin_amount');
  275. return $value === null ? false : bcadd((string) $value, '0', 4);
  276. });
  277. return $margin === false ? null : $margin;
  278. }
  279. /**
  280. * 方案保证金和停止比例的短时缓存(同一实例内 30 秒),避免实时分成逐笔查询时在代理行锁内反复查库。
  281. * 总后台修改方案或配置在其他请求中完成,最多 30 秒后生效到分成任务。
  282. */
  283. private function lookup(string $key, callable $resolve)
  284. {
  285. $now = time();
  286. if (!isset($this->lookups[$key]) || $this->lookups[$key][1] < $now) {
  287. $this->lookups[$key] = [$resolve(), $now + 30];
  288. }
  289. return $this->lookups[$key][0];
  290. }
  291. public function effectiveMargin(Agent $agent): string
  292. {
  293. $margin = bcadd((string) $agent->margin_amount, '0', 4);
  294. $realtime = bcadd((string) $agent->realtime_balance, '0', 4);
  295. $value = bccomp($realtime, $margin, 4) < 0 ? $realtime : $margin;
  296. return bccomp($value, '0', 4) < 0 ? '0.0000' : $value;
  297. }
  298. /** 代理端可见的资金构成(不含实时余额)。 */
  299. public function funds(Agent $agent): array
  300. {
  301. $balance = bcadd((string) $agent->balance, '0', 4);
  302. $margin = bcadd((string) $agent->margin_amount, '0', 4);
  303. $data = [
  304. 'balance' => $balance,
  305. 'frozen_balance' => bcadd((string) $agent->frozen_balance, '0', 4),
  306. 'withdrawable_amount' => $this->withdrawable($agent),
  307. ];
  308. if ($agent->isJoint()) {
  309. $marginBalance = bccomp($balance, $margin, 4) < 0 ? $balance : $margin;
  310. $data += [
  311. 'margin_amount' => $margin,
  312. 'margin_balance' => bccomp($marginBalance, '0', 4) < 0 ? '0.0000' : $marginBalance,
  313. 'profit_amount' => bcsub($balance, $margin, 4),
  314. ];
  315. } else {
  316. $data['equity_percent'] = bcadd((string) $agent->equity_percent, '0', 4);
  317. // 兼容历史记录,锁定金额最多展示到当前余额。
  318. $locked = $this->lockedAmount($agent);
  319. $shown = bccomp($locked, $balance, 4) > 0 ? $balance : $locked;
  320. $data['locked_amount'] = bccomp($shown, '0', 4) > 0 ? $shown : '0.0000';
  321. $data['recharge_lock_days'] = (int) AgentSetting::current()->equity_recharge_lock_days;
  322. }
  323. return $data;
  324. }
  325. /**
  326. * 可提现金额。合营:总余额加上「待入账亏损的最坏情况」后,超出保证金本金的部分;入股:全部余额。
  327. */
  328. public function withdrawable(Agent $agent): string
  329. {
  330. $balance = bcadd((string) $agent->balance, '0', 4);
  331. if (!$agent->isJoint()) {
  332. // 入股:锁定期内的充值不能提现
  333. $free = bcsub($balance, $this->lockedAmount($agent), 4);
  334. return bccomp($free, '0', 4) > 0 ? $free : '0.0000';
  335. }
  336. $available = bcsub(bcadd($balance, $this->pendingFloor($agent), 4), (string) $agent->margin_amount, 4);
  337. return bccomp($available, '0', 4) > 0 ? $available : '0.0000';
  338. }
  339. /**
  340. * 待入账分成按结算日依次入账时,余额最多会下降多少(≤0)。
  341. * 取未入账分成按结算日的前缀和的最小值;传 $fromDate 时只看该日及之后的入账点
  342. * (新增或修改该日分成会影响这些点),用于限制亏损扣减。
  343. */
  344. public function pendingFloor(Agent $agent, ?string $fromDate = null): string
  345. {
  346. if (!$agent->isJoint()) return '0.0000';
  347. $sums = AgentProfitEntry::query()->toBase()->where('agent_id', $agent->id)
  348. ->where('status', AgentProfitEntry::STATUS_COUNTED)->whereNull('rebate_record_id')
  349. ->groupBy('settle_date')->orderBy('settle_date')
  350. ->selectRaw('settle_date, COALESCE(SUM(share_amount),0) total')->get();
  351. $running = '0.0000';
  352. $floor = $fromDate === null ? '0.0000' : null;
  353. foreach ($sums as $row) {
  354. if ($floor === null && substr((string) $row->settle_date, 0, 10) >= $fromDate) $floor = $running;
  355. $running = bcadd($running, (string) $row->total, 4);
  356. if ($floor !== null && bccomp($running, $floor, 4) < 0) $floor = $running;
  357. }
  358. return $floor ?? $running;
  359. }
  360. private function setActive(Agent $agent, bool $active): bool
  361. {
  362. if ((int) $agent->profit_active !== ($active ? 1 : 0)) {
  363. $agent->profit_active = $active ? 1 : 0;
  364. $agent->profit_active_changed_at = now();
  365. $agent->save();
  366. }
  367. return $active;
  368. }
  369. private function assertSameAdjustment(AgentBalanceLog $log, int $agentId, string $type, string $amount): void
  370. {
  371. $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
  372. if ((int) $log->agent_id !== $agentId || $log->type !== $type || bccomp((string) $log->amount, $signed, 4) !== 0) {
  373. throw new \RuntimeException('request_id 已用于其他资金调整');
  374. }
  375. }
  376. }