paymentChannels(); config(['app.url' => 'https://api.example.test', 'app.jd_pay_mch_id' => 'JD-MCH', 'app.jd_pay_key' => 'jd-secret']); $wallets = new AgentWalletService(); $this->payments = new AgentPaymentService($wallets, new AgentEquityService($wallets)); } /** 直接写库:其他测试类可能已缓存 agent_settings 的旧列清单,fill() 会丢掉新列。 */ private function useGroup(int $groupId): void { AgentSetting::current(); DB::table('agent_settings')->where('id', 1)->update(['recharge_channel_group_id' => $groupId]); } private function onlineOrder(Agent $agent, string $amount = '100.00'): AgentPaymentOrder { return AgentPaymentOrder::query()->create([ 'agent_id' => $agent->id, 'type' => PaymentOrderService::TYPE_PAY, 'member_id' => AgentPaymentService::payerId($agent), 'order_no' => PaymentOrderService::createOrderNo('agjd1_', AgentPaymentService::payerId($agent)), 'amount' => $amount, 'channel' => JdPayService::CHANNEL, 'bank_name' => 'JD钱包', 'status' => PaymentOrderService::STATUS_PROCESS, ]); } private function jdCallback(AgentPaymentOrder $order, string $status = JdPayService::PAY_STATUS_SUCCESS): array { $amount = JdPayService::amount($order->amount); $params = ['userCode' => 'JD-MCH', 'orderCode' => $order->order_no, 'amount' => $amount, 'status' => $status]; $params['sign'] = JdPayService::signature([$order->order_no, $amount, 'JD-MCH', $status], 'jd-secret'); return $params; } public function test_channels_follow_the_configured_group_and_provider_whitelists(): void { $this->payments(); $this->useGroup(2); $types = array_column($this->payments->channels(), 'value'); $this->assertEqualsCanonicalizing(['usdt', 'rgcz', 'JDpay', 'NOpay12', 'NOpay13', 'ZIMUpay'], $types); config(['app.jd_pay_recharge_user_ids' => '1001,1002']); $this->assertNotContains('JDpay', array_column($this->payments->channels(), 'value'), 'member-only whitelist hides the channel from agents'); DB::table('payment_providers')->where('code', 'no')->update(['status' => 0]); $this->assertNotContains('NOpay12', array_column($this->payments->channels(), 'value')); } public function test_online_order_rejects_channels_outside_the_group_or_disabled_providers(): void { $this->payments(); $this->useGroup(2); $agent = $this->agent(); foreach (['wxsm' => '不支持此充值方式', 'rgcz' => '该通道请使用对应的充值接口'] as $type => $message) { try { $this->payments->createOnline($agent, '100', $type); $this->fail($type); } catch (\Exception $e) { $this->assertSame($message, $e->getMessage()); } } DB::table('payment_providers')->where('code', 'zimu')->update(['status' => 0]); $this->expectExceptionMessage('支付公司已禁用'); $this->payments->createOnline($agent, '100', 'ZIMUpay'); } public function test_provider_callback_credits_the_agent_once_and_counts_as_margin(): void { $this->payments(); $agent = $this->agent(); $order = $this->onlineOrder($agent, '100.00'); $this->assertSame($order->id, PaymentOrderService::findPayOrder($order->order_no)->id); $this->assertInstanceOf(AgentPaymentOrder::class, PaymentOrderService::findPayOrder($order->order_no)); $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order))); $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order)), 'replayed callback is acknowledged'); $agent = $agent->fresh(); $this->assertSame('100.0000', $agent->balance); $this->assertSame('100.0000', $agent->margin_amount, 'joint agent recharges are margin'); $this->assertSame(PaymentOrderService::STATUS_SUCCESS, $order->fresh()->status); $this->assertSame(1, DB::table('agent_balance_logs')->where('agent_id', $agent->id)->where('type', 'recharge')->count()); $this->assertSame(0, DB::table('payment_orders')->count(), 'agent orders never land in member payment_orders'); $this->assertSame(0, DB::table('wallets')->count()); } public function test_bad_signature_and_failed_payment_do_not_credit(): void { $this->payments(); $agent = $this->agent(); $order = $this->onlineOrder($agent); $forged = $this->jdCallback($order); $forged['sign'] = 'X'; $this->assertFalse(PaymentOrderService::receivePay($forged)); $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order, JdPayService::PAY_STATUS_FAIL))); $this->assertSame(PaymentOrderService::STATUS_FAIL, $order->fresh()->status); $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order)), 'late success after failure is ignored'); $this->assertSame('0.0000', $agent->fresh()->balance); } public function test_paid_online_recharge_opens_a_pending_equity_order(): void { $this->payments(); AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 1000, 'equity_min_percent' => 0.1, 'equity_max_percent' => 1]); $agent = $this->agent(Agent::TYPE_EQUITY); $equity = (new AgentEquityService(new AgentWalletService()))->order($agent, '0.1'); $this->assertSame(AgentEquityOrder::STATUS_PENDING, $equity->status); PaymentOrderService::receivePay($this->jdCallback($this->onlineOrder($agent, '100.00'))); $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $equity->fresh()->status); $this->assertSame('0.1000', $agent->fresh()->equity_percent); $this->assertSame('0.0000', $agent->fresh()->balance); } public function test_manual_recharge_needs_pay_data_proof_and_audit(): void { $this->payments(); $this->useGroup(2); $agent = $this->agent(); $order = $this->payments->createManual($agent, '500', 1); $this->assertStringStartsWith('agrgcz_', $order->order_no); try { $this->payments->submitImage($agent, $order->id, 'https://example.test/a.png'); $this->fail('proof before pay data'); } catch (\RuntimeException $e) { $this->assertSame('待处理中,请稍后', $e->getMessage()); } $this->assertSame(1, $this->payments->setPayData([$order->id], 1, '工商银行 6222… 张三')); try { $this->payments->manualAudit($order->id, 2, '', 1); $this->fail('audit before proof'); } catch (\RuntimeException $e) { $this->assertSame('代理未提交充值凭证', $e->getMessage()); } $this->payments->submitImage($agent, $order->id, 'https://example.test/a.png'); $this->payments->manualAudit($order->id, 2, 'ok', 1); $this->assertSame('500.0000', $agent->fresh()->balance); $this->assertSame(PaymentOrderService::STATUS_SUCCESS, $order->fresh()->status); $this->expectExceptionMessage('订单已完成,不可驳回'); $this->payments->manualAudit($order->id, 3, 'late', 1); } public function test_manual_recharge_requires_rgcz_in_the_agent_group(): void { $this->payments(); $this->expectExceptionMessage('不支持此充值方式'); $this->payments->createManual($this->agent(), '500', 1); // group 1 has no rgcz } /** 模拟 JD 下单返回,返回记录到的请求。 */ private function fakeJd(array $response): \ArrayObject { $history = new \ArrayObject(); $stack = HandlerStack::create(new MockHandler([new Response(200, [], json_encode($response))])); $stack->push(Middleware::history($history)); JdPayService::$handler = $stack; config(['app.jd_pay_gateway' => 'https://jd.example.test/pay']); return $history; } protected function tearDown(): void { JdPayService::$handler = null; parent::tearDown(); } public function test_online_order_is_created_in_agent_table_and_hides_raw_provider_response(): void { $this->payments(); $this->useGroup(2); $agent = $this->agent(); DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->update(['rate' => '0.0200']); $history = $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-1', 'url' => '']]); $result = $this->payments->createOnline($agent, '100.50', 'JDpay'); $order = $result['order']; // 三方手续费是平台成本:记录费率快照,代理不可见,入账仍是全额。 $this->assertSame(['0.020000', '0.0000', '2.0100'], [bcadd((string) $order->fee_rate, '0', 6), bcadd((string) $order->fee_fixed, '0', 4), $order->fee]); $this->assertArrayNotHasKey('fee', $this->payments->paginate([], $agent)['list'][0]->toArray(), 'channel cost is hidden from the agent'); $this->assertStringStartsWith('agjd1_', $order->order_no); parse_str((string) $history[0]['request']->getBody(), $sent); $this->assertSame($order->order_no, $sent['orderCode']); $this->assertSame('100.50', $sent['amount']); $this->assertSame(PaymentOrderService::STATUS_PROCESS, $order->status); $this->assertSame('JD-PAY-1', $order->pay_no); $this->assertArrayNotHasKey('pay_data', $order->toArray(), 'raw provider response is not returned to the agent'); $this->assertArrayNotHasKey('pay_data', $this->payments->paginate([], $agent)['list'][0]->toArray()); $this->assertNotEmpty($this->payments->paginate([])['list'][0]->toArray()['pay_data'], 'admin still sees it'); $this->assertSame(0, DB::table('payment_orders')->count()); $this->assertTrue(PaymentOrderService::receivePay($this->jdCallback($order))); $this->assertSame('100.5000', $agent->fresh()->balance); } public function test_member_online_order_still_lands_in_payment_orders(): void { $this->payments(); $this->fullPaymentOrders(); DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->update(['rate' => '0.0150']); $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-2', 'url' => '']]); $result = PaymentOrderService::createPayOrder(PaymentOrder::class, 'M1001', 100, 'JDpay', 2, RechargeChannel::checkRechargeChannel('JDpay', 2)); $this->assertSame(0, $result['code']); $this->assertStringStartsWith('jd1_', $result['order_no']); $found = PaymentOrderService::findPayOrder($result['order_no']); $this->assertSame(['0.015000', '1.5000'], [bcadd((string) $found->fee_rate, '0', 6), bcadd((string) $found->fee, '0', 4)]); $this->assertSame(PaymentOrder::class, get_class($found)); $this->assertSame('M1001', $found->member_id); $this->assertSame(0, DB::table('agent_payment_orders')->count()); // 前缀约定被打破(会员订单号以 ag 开头)时回退到另一张表,不丢回调 DB::table('payment_orders')->where('id', $found->id)->update(['order_no' => 'ag-member-1']); $this->assertSame($found->id, PaymentOrderService::findPayOrder('ag-member-1')->id); $this->assertInstanceOf(PaymentOrder::class, PaymentOrderService::findPayOrder('ag-member-1')); $this->assertNotInstanceOf(AgentPaymentOrder::class, PaymentOrderService::findPayOrder('ag-member-1')); } public function test_fee_snapshot_migration_backfills_rates_from_recorded_fees(): void { $this->payments(); $this->fullPaymentOrders(); $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_120000_add_fee_snapshots_to_payment_orders.php'; $migration->down(); $order = fn (int $type, string $amount, string $fee) => ['type' => $type, 'order_no' => 'o' . $type . $amount, 'member_id' => 'M1', 'amount' => $amount, 'fee' => $fee, 'status' => 2]; DB::table('payment_orders')->insert([$order(1, '100', '2'), $order(2, '100', '3'), $order(4, '50', '50'), $order(3, '80', '0')]); DB::table('agent_payment_orders')->insert(['agent_id' => 1, 'type' => 1, 'order_no' => 'ag1', 'amount' => '200', 'fee' => '1']); $withdrawal = fn (string $channel, string $no) => ['order_no' => $no, 'request_key' => $no, 'agent_id' => 1, 'account_type' => $channel, 'account_snapshot' => '{}', 'amount' => '100', 'fee' => '1', 'actual_amount' => '99', 'status' => 'paid']; DB::table('agent_withdrawals')->insert([$withdrawal('JDpay', 'w1'), $withdrawal('usdt', 'w2'), $withdrawal('', 'w3')]); $migration->up(); $rates = fn (string $table) => DB::table($table)->orderBy('id')->get() ->map(fn ($row) => [bcadd((string) $row->fee_rate, '0', 6), bcadd((string) $row->fee_fixed, '0', 4)])->all(); $this->assertSame([['0.020000', '0.0000'], ['0.010000', '2.0000'], ['1.000000', '0.0000'], ['0.000000', '0.0000']], $rates('payment_orders')); $this->assertSame([['0.005000', '0.0000']], $rates('agent_payment_orders')); $rows = DB::table('agent_withdrawals')->orderBy('id')->get(); $this->assertSame(['0.010000', '0.010000', '0.010000'], $rows->map(fn ($row) => bcadd((string) $row->fee_rate, '0', 6))->all()); $this->assertSame([null, '0', '0'], $rows->map(fn ($row) => $row->channel_fee === null ? null : bcadd((string) $row->channel_fee, '0', 0))->all(), 'historical provider cost is unknown; USDT and manual payouts have none'); } public function test_manual_recharge_respects_channel_limits_and_can_be_cancelled_to_free_a_slot(): void { $this->payments(); $this->useGroup(2); $agent = $this->agent(); try { $this->payments->createManual($agent, '5', 1); $this->fail('below rgcz min'); } catch (\RuntimeException $e) { $this->assertStringContainsString('10-500000', $e->getMessage()); } $orders = []; for ($i = 0; $i < 5; $i++) $orders[] = $this->payments->createManual($agent, '100', 1); $this->payments->setPayData([$orders[0]->id], 1, '收款信息'); try { $this->payments->createManual($agent, '100', 1); $this->fail('open limit'); } catch (\RuntimeException $e) { $this->assertSame('未完成的人工充值过多,请等待处理', $e->getMessage()); } $cancelled = $this->payments->cancelManual($agent, $orders[0]->id); // 待提交凭证可撤回 $this->assertSame(PaymentOrderService::STATUS_CANCEL, $cancelled->status); $this->payments->createManual($agent, '100', 1); $this->payments->setPayData([$orders[1]->id], 1, '收款信息'); $this->payments->submitImage($agent, $orders[1]->id, 'https://example.test/a.png'); $this->expectExceptionMessage('当前状态不可撤回'); $this->payments->cancelManual($agent, $orders[1]->id); } }