paymentChannels(); if (!\Illuminate\Support\Facades\Schema::hasColumn('payment_orders', 'order_no')) $this->fullPaymentOrders(); AgentSetting::current(); DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => $groupId]); config(['app.url' => 'https://api.example.test', 'app.jd_pay_mch_id' => 'JD-MCH', 'app.jd_pay_key' => 'jd-secret', 'app.jd_remit_gateway' => 'https://jd.example.test/remit', 'app.jd_balance_gateway' => 'https://jd.example.test/balance']); $this->withdrawals = new AgentWithdrawalService(); } protected function tearDown(): void { JdPayService::$handler = null; parent::tearDown(); } private function funded(string $balance = '1000', float $feeRate = 1): Agent { $agent = $this->agent(Agent::TYPE_EQUITY); Agent::query()->whereKey($agent->id)->update(['balance' => $balance, 'realtime_balance' => $balance, 'withdraw_fee_rate' => $feeRate]); return $agent->fresh(); } private function fakeJd(array ...$responses): \ArrayObject { $history = new \ArrayObject(); $stack = HandlerStack::create(new MockHandler(array_map(fn ($r) => new Response(200, [], json_encode($r)), $responses))); $stack->push(Middleware::history($history)); JdPayService::$handler = $stack; return $history; } private function remitCallback(AgentWithdrawal $withdrawal, string $status): array { $amount = JdPayService::amount($withdrawal->actual_amount); $params = ['userCode' => 'JD-MCH', 'orderCode' => 'JDR-1', 'customerOrderCode' => $withdrawal->order_no, 'amount' => $amount, 'status' => $status]; $params['sign'] = JdPayService::signature(['JDR-1', $withdrawal->order_no, $amount, 'JD-MCH', $status], 'jd-secret'); return $params; } public function test_channels_follow_the_agent_group_with_member_form_flags(): void { $this->setUpChannels(2); $result = $this->withdrawals->channels($this->agent()); $this->assertEqualsCanonicalizing(['rgtx', 'JDpay', 'NOwithdraw', 'ZIMUwithdraw'], array_column($result['list'], 'value')); $byValue = array_column($result['list'], null, 'value'); $this->assertSame(['bank', 'wechat', 'alipay'], array_column($byValue['rgtx']['payout_methods'], 'value')); $this->assertFalse($byValue['JDpay']['requires_real_name'], 'JD wallet pays by address only'); $this->assertTrue($byValue['NOwithdraw']['requires_real_name']); $this->setUpChannels(1); $this->assertContains('usdt', array_column($this->withdrawals->channels($this->agent())['list'], 'value')); } public function test_accounts_validate_like_member_binding_and_bind_real_name_once(): void { $this->setUpChannels(2); $agent = $this->agent(); try { $this->withdrawals->saveAccount($agent, ['type' => 'usdt', 'network' => 'TRC20', 'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE']); $this->fail('usdt not in group 2'); } catch (\RuntimeException $e) { $this->assertSame('不支持此提现方式', $e->getMessage()); } try { $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'bank_branch' => '深圳支行', 'card_no' => '6222']); $this->fail('real name'); } catch (\InvalidArgumentException $e) { $this->assertSame('请输入真实姓名', $e->getMessage()); } try { $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'card_no' => '6222', 'real_name' => '张三']); $this->fail('branch'); } catch (\InvalidArgumentException $e) { $this->assertSame('请输入开户行支行名称', $e->getMessage()); } $bank = $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'bank', 'bank_name' => '工商银行', 'bank_branch' => '深圳支行', 'card_no' => '6222', 'real_name' => '张三']); $this->assertSame(['rgtx', '6222', '工商银行', '深圳支行', 'bank', '张三'], [$bank['channel'], $bank['card_no'], $bank['bank_name'], $bank['bank_branch'], $bank['payout_method'], $bank['holder_name']]); $this->assertSame('张三', $agent->fresh()->real_name); $this->withdrawals->saveAccount($agent, ['type' => 'NOwithdraw', 'card_no' => 'NO-1', 'real_name' => '李四']); $this->assertSame('张三', $agent->fresh()->real_name, 'real name is bound once'); $jd = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']); $this->assertSame(['JD钱包', ''], [$jd['bank_name'], $jd['holder_name']]); $this->assertCount(3, $this->withdrawals->accounts($agent)); $this->assertCount(1, $this->withdrawals->accounts($agent, 'JDpay')); } public function test_jd_withdrawal_pays_out_through_provider_and_callback_settles_once(): void { $this->setUpChannels(2); $agent = $this->funded('1000', 1); $account = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']); $withdrawal = $this->withdrawals->request($agent, $account['id'], '100.00', 'withdraw-old', 'jd-1'); $this->assertSame(['JDpay', '100.0000', '1.0000', '99.0000', 'pending'], [$withdrawal->channel, $withdrawal->amount, $withdrawal->fee, $withdrawal->actual_amount, $withdrawal->status]); // 代理手续费 1% 是收入;平台付给 JD 的成本 = 到账 99 × 0.2% + 2,只给总后台看。 $this->assertSame(['0.010000', '0.002000', '2.0000', '2.1980'], [$withdrawal->fee_rate, $withdrawal->channel_fee_rate, $withdrawal->channel_fee_fixed, $withdrawal->channel_fee]); $this->assertArrayNotHasKey('channel_fee', $withdrawal->toArray(), 'channel cost is admin-only'); $this->assertSame(['900.0000', '100.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]); $history = $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]); $approved = $this->withdrawals->audit($withdrawal->id, 'approve', 1); $this->assertSame(['processing', 'JDR-1'], [$approved->status, $approved->pay_no]); parse_str((string) $history[1]['request']->getBody(), $sent); $this->assertSame([$withdrawal->order_no, '99.00', 'JD-ADDR'], [$sent['orderCode'], $sent['amount'], $sent['address']], 'pays the amount after the agent fee'); try { $this->withdrawals->audit($withdrawal->id, 'reject', 1, 'late'); $this->fail('processing cannot be rejected'); } catch (\RuntimeException $e) { $this->assertSame('当前订单不能驳回', $e->getMessage()); } $forged = $this->remitCallback($approved, JdPayService::REMIT_STATUS_SUCCESS); $forged['amount'] = '100.00'; $this->assertFalse(PaymentOrderService::receiveOrder($forged), 'amount must match the payout'); $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_SUCCESS))); $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_FAIL)), 'late failure is ignored'); $paid = $withdrawal->fresh(); $this->assertSame('paid', $paid->status); $this->assertNotNull($paid->paid_at); $this->assertSame(['900.0000', '0.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]); $this->assertArrayNotHasKey('pay_data', $paid->toArray(), 'raw provider data stays admin-only'); } public function test_failed_payout_refunds_and_provider_error_keeps_order_pending(): void { $this->setUpChannels(2); $agent = $this->funded('1000', 0); $account = $this->withdrawals->saveAccount($agent, ['type' => 'JDpay', 'card_no' => 'JD-ADDR']); $withdrawal = $this->withdrawals->request($agent, $account['id'], '200', 'withdraw-old', 'jd-2'); $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 500, 'message' => '通道维护']); try { $this->withdrawals->audit($withdrawal->id, 'approve', 1); $this->fail('provider error'); } catch (\RuntimeException $e) { $this->assertSame('通道维护', $e->getMessage()); } $this->assertSame('pending', $withdrawal->fresh()->status); $this->assertSame('200.0000', $agent->fresh()->frozen_balance); $this->fakeJd(['code' => 200, 'data' => ['balance' => '5000']], ['code' => 200, 'data' => ['orderNo' => 'JDR-1']]); $approved = $this->withdrawals->audit($withdrawal->id, 'approve', 1); $this->assertTrue(PaymentOrderService::receiveOrder($this->remitCallback($approved, JdPayService::REMIT_STATUS_FAIL))); $this->assertSame('failed', $withdrawal->fresh()->status); $this->assertSame(['1000.0000', '0.0000'], [$agent->fresh()->balance, $agent->fresh()->frozen_balance]); $this->assertSame(1, DB::table('agent_balance_logs')->where('type', 'withdraw_refund')->count()); } public function test_manual_channels_and_limits(): void { $this->setUpChannels(2); $agent = $this->funded('100000', 0); $account = $this->withdrawals->saveAccount($agent, ['type' => 'rgtx', 'payout_method' => 'alipay', 'card_no' => 'ali@example.test', 'qr_image' => 'https://example.test/qr.png', 'real_name' => '张三']); foreach (['50' => '提现金额范围为100-50000', '100.123' => '提现金额最多 2 位小数'] as $amount => $message) { try { $this->withdrawals->request($agent, $account['id'], (string) $amount, 'withdraw-old', 'bad-' . $amount); $this->fail($message); } catch (\Exception $e) { $this->assertSame($message, $e->getMessage()); } } $withdrawal = $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'manual'); $this->assertSame('张三', $withdrawal->account_snapshot['holder_name']); $this->assertSame('https://example.test/qr.png', $withdrawal->account_snapshot['qr_image']); $this->assertSame(['0.000000', '0.0000'], [$withdrawal->channel_fee_rate, $withdrawal->channel_fee], 'manual payout has no channel cost'); $this->assertSame('approved', $this->withdrawals->audit($withdrawal->id, 'approve', 1)->status, 'no provider call for manual payout'); $this->assertSame('paid', $this->withdrawals->audit($withdrawal->id, 'paid', 1)->status); $this->assertSame('0.0000', $agent->fresh()->frozen_balance); DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => 1]); // 组合 1 没有人工提现 $this->expectExceptionMessage('不支持此提现方式'); $this->withdrawals->request($agent, $account['id'], '500', 'withdraw-old', 'outside-group'); } }