AgentWithdrawalChannelTest.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentSetting;
  5. use App\Models\Agent\AgentWithdrawal;
  6. use App\Services\Agent\AgentWithdrawalService;
  7. use App\Services\Payment\JdPayService;
  8. use App\Services\PaymentOrderService;
  9. use GuzzleHttp\Handler\MockHandler;
  10. use GuzzleHttp\HandlerStack;
  11. use GuzzleHttp\Middleware;
  12. use GuzzleHttp\Psr7\Response;
  13. use Illuminate\Support\Facades\DB;
  14. use PHPUnit\Framework\TestCase;
  15. use Tests\Support\AgentTestEnvironment;
  16. /** 代理提现使用总后台配置的提现通道:账户绑定规则、三方代付下单与回调、人工通道。 */
  17. class AgentWithdrawalChannelTest extends TestCase
  18. {
  19. use AgentTestEnvironment;
  20. private AgentWithdrawalService $withdrawals;
  21. private function setUpChannels(int $groupId = 2): void
  22. {
  23. $this->paymentChannels();
  24. if (!\Illuminate\Support\Facades\Schema::hasColumn('payment_orders', 'order_no')) $this->fullPaymentOrders();
  25. AgentSetting::current();
  26. DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => $groupId]);
  27. config(['app.url' => 'https://api.example.test', 'app.jd_pay_mch_id' => 'JD-MCH', 'app.jd_pay_key' => 'jd-secret',
  28. 'app.jd_remit_gateway' => 'https://jd.example.test/remit', 'app.jd_balance_gateway' => 'https://jd.example.test/balance']);
  29. $this->withdrawals = new AgentWithdrawalService();
  30. }
  31. protected function tearDown(): void
  32. {
  33. JdPayService::$handler = null;
  34. parent::tearDown();
  35. }
  36. private function funded(string $balance = '1000', float $feeRate = 1): Agent
  37. {
  38. $agent = $this->agent(Agent::TYPE_EQUITY);
  39. Agent::query()->whereKey($agent->id)->update(['balance' => $balance, 'realtime_balance' => $balance, 'withdraw_fee_rate' => $feeRate]);
  40. return $agent->fresh();
  41. }
  42. private function fakeJd(array ...$responses): \ArrayObject
  43. {
  44. $history = new \ArrayObject();
  45. $stack = HandlerStack::create(new MockHandler(array_map(fn ($r) => new Response(200, [], json_encode($r)), $responses)));
  46. $stack->push(Middleware::history($history));
  47. JdPayService::$handler = $stack;
  48. return $history;
  49. }
  50. private function remitCallback(AgentWithdrawal $withdrawal, string $status): array
  51. {
  52. $amount = JdPayService::amount($withdrawal->actual_amount);
  53. $params = ['userCode' => 'JD-MCH', 'orderCode' => 'JDR-1', 'customerOrderCode' => $withdrawal->order_no, 'amount' => $amount, 'status' => $status];
  54. $params['sign'] = JdPayService::signature(['JDR-1', $withdrawal->order_no, $amount, 'JD-MCH', $status], 'jd-secret');
  55. return $params;
  56. }
  57. public function test_channels_follow_the_agent_group_with_member_form_flags(): void
  58. {
  59. $this->setUpChannels(2);
  60. $result = $this->withdrawals->channels($this->agent());
  61. $this->assertEqualsCanonicalizing(['rgtx', 'JDpay', 'NOwithdraw', 'ZIMUwithdraw'], array_column($result['list'], 'value'));
  62. $byValue = array_column($result['list'], null, 'value');
  63. $this->assertSame(['bank', 'wechat', 'alipay'], array_column($byValue['rgtx']['payout_methods'], 'value'));
  64. $this->assertFalse($byValue['JDpay']['requires_real_name'], 'JD wallet pays by address only');
  65. $this->assertTrue($byValue['NOwithdraw']['requires_real_name']);
  66. $this->setUpChannels(1);
  67. $this->assertContains('usdt', array_column($this->withdrawals->channels($this->agent())['list'], 'value'));
  68. }
  69. public function test_accounts_validate_like_member_binding_and_bind_real_name_once(): void
  70. {
  71. $this->setUpChannels(2);
  72. $agent = $this->agent();
  73. try { $this->withdrawals->saveAccount($agent, ['type' => 'usdt', 'network' => 'TRC20', 'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE']); $this->fail('usdt not in group 2'); }
  74. catch (\RuntimeException $e) { $this->assertSame('不支持此提现方式', $e->getMessage()); }
  75. try { $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'bank_branch' => '深圳支行', 'card_no' => '6222']); $this->fail('real name'); }
  76. catch (\InvalidArgumentException $e) { $this->assertSame('请输入真实姓名', $e->getMessage()); }
  77. try { $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'card_no' => '6222', 'real_name' => '张三']); $this->fail('branch'); }
  78. catch (\InvalidArgumentException $e) { $this->assertSame('请输入开户行支行名称', $e->getMessage()); }
  79. $bank = $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行',
  80. 'bank_branch' => '深圳支行', 'card_no' => '6222', 'real_name' => '张三']);
  81. $this->assertSame(['rgtx', '6222', '工商银行', '深圳支行', 'bank', '张三'],
  82. [$bank['channel'], $bank['card_no'], $bank['bank_name'], $bank['bank_branch'], $bank['payout_method'], $bank['holder_name']]);
  83. $this->assertSame('张三', $agent->fresh()->real_name);
  84. $this->withdrawals->saveAccount($agent, ['type' => 'NOwithdraw', 'card_no' => 'NO-1', 'real_name' => '李四']);
  85. $this->assertSame('张三', $agent->fresh()->real_name, 'real name is bound once');
  86. $jd = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']);
  87. $this->assertSame(['JD钱包', ''], [$jd['bank_name'], $jd['holder_name']]);
  88. $this->assertCount(3, $this->withdrawals->accounts($agent));
  89. $this->assertCount(1, $this->withdrawals->accounts($agent, 'JDpay'));
  90. }
  91. public function test_jd_withdrawal_pays_out_through_provider_and_callback_settles_once(): void
  92. {
  93. $this->setUpChannels(2);
  94. $agent = $this->funded('1000', 1);
  95. $account = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']);
  96. $withdrawal = $this->withdrawals->request($agent, $account['id'], '100.00', 'withdraw-old', 'jd-1');
  97. $this->assertSame(['JDpay', '100.0000', '1.0000', '99.0000', 'pending'],
  98. [$withdrawal->channel, $withdrawal->amount, $withdrawal->fee, $withdrawal->actual_amount, $withdrawal->status]);
  99. $this->assertSame(['900.0000', '100.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
  100. $history = $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]);
  101. $approved = $this->withdrawals->audit($withdrawal->id, 'approve', 1);
  102. $this->assertSame(['processing', 'JDR-1'], [$approved->status, $approved->pay_no]);
  103. parse_str((string) $history[1]['request']->getBody(), $sent);
  104. $this->assertSame([$withdrawal->order_no, '99.00', 'JD-ADDR'], [$sent['orderCode'], $sent['amount'], $sent['address']], 'pays the amount after the agent fee');
  105. try { $this->withdrawals->audit($withdrawal->id, 'reject', 1, 'late'); $this->fail('processing cannot be rejected'); }
  106. catch (\RuntimeException $e) { $this->assertSame('当前订单不能驳回', $e->getMessage()); }
  107. $forged = $this->remitCallback($approved, JdPayService::REMIT_STATUS_SUCCESS);
  108. $forged['amount'] = '100.00';
  109. $this->assertFalse(PaymentOrderService::receiveOrder($forged), 'amount must match the payout');
  110. $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_SUCCESS)));
  111. $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_FAIL)), 'late failure is ignored');
  112. $paid = $withdrawal->fresh();
  113. $this->assertSame('paid', $paid->status);
  114. $this->assertNotNull($paid->paid_at);
  115. $this->assertSame(['900.0000', '0.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
  116. $this->assertArrayNotHasKey('pay_data', $paid->toArray(), 'raw provider data stays admin-only');
  117. }
  118. public function test_failed_payout_refunds_and_provider_error_keeps_order_pending(): void
  119. {
  120. $this->setUpChannels(2);
  121. $agent = $this->funded('1000', 0);
  122. $account = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']);
  123. $withdrawal = $this->withdrawals->request($agent, $account['id'], '200', 'withdraw-old', 'jd-2');
  124. $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 500, 'message' => '通道维护']);
  125. try { $this->withdrawals->audit($withdrawal->id, 'approve', 1); $this->fail('provider error'); }
  126. catch (\RuntimeException $e) { $this->assertSame('通道维护', $e->getMessage()); }
  127. $this->assertSame('pending', $withdrawal->fresh()->status);
  128. $this->assertSame('200.0000', $agent->fresh()->frozen_balance);
  129. $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]);
  130. $approved = $this->withdrawals->audit($withdrawal->id, 'approve', 1);
  131. $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_FAIL)));
  132. $this->assertSame('failed', $withdrawal->fresh()->status);
  133. $this->assertSame(['1000.0000', '0.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]);
  134. $this->assertSame(1, DB::table('agent_balance_logs')->where('type', 'withdraw_refund')->count());
  135. }
  136. public function test_manual_channels_and_limits(): void
  137. {
  138. $this->setUpChannels(2);
  139. $agent = $this->funded('100000', 0);
  140. $account = $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'alipay', 'card_no' => 'ali@example.test',
  141. 'qr_image' => 'https://example.test/qr.png', 'real_name' => '张三']);
  142. foreach (['50' => '提现金额范围为100-50000', '100.123' => '提现金额最多 2 位小数'] as $amount => $message) {
  143. try { $this->withdrawals->request($agent, $account['id'], (string) $amount, 'withdraw-old', 'bad-' . $amount); $this->fail($message); }
  144. catch (\Exception $e) { $this->assertSame($message, $e->getMessage()); }
  145. }
  146. $withdrawal = $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'manual');
  147. $this->assertSame('张三', $withdrawal->account_snapshot['holder_name']);
  148. $this->assertSame('https://example.test/qr.png', $withdrawal->account_snapshot['qr_image']);
  149. $this->assertSame('approved', $this->withdrawals->audit($withdrawal->id, 'approve', 1)->status, 'no provider call for manual payout');
  150. $this->assertSame('paid', $this->withdrawals->audit($withdrawal->id, 'paid', 1)->status);
  151. $this->assertSame('0.0000', $agent->fresh()->frozen_balance);
  152. DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => 1]); // 组合 1 没有人工提现
  153. $this->expectExceptionMessage('不支持此提现方式');
  154. $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'outside-group');
  155. }
  156. }