|
|
@@ -0,0 +1,271 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Tests\Integration;
|
|
|
+
|
|
|
+use App\Models\Agent\Agent;
|
|
|
+use App\Models\Agent\AgentEquityOrder;
|
|
|
+use App\Models\Agent\AgentPaymentOrder;
|
|
|
+use App\Models\Agent\AgentSetting;
|
|
|
+use App\Models\PaymentOrder;
|
|
|
+use App\Models\RechargeChannel;
|
|
|
+use App\Services\Agent\AgentEquityService;
|
|
|
+use App\Services\Agent\AgentPaymentService;
|
|
|
+use App\Services\Agent\AgentWalletService;
|
|
|
+use App\Services\Payment\JdPayService;
|
|
|
+use App\Services\PaymentOrderService;
|
|
|
+use GuzzleHttp\Handler\MockHandler;
|
|
|
+use GuzzleHttp\HandlerStack;
|
|
|
+use GuzzleHttp\Middleware;
|
|
|
+use GuzzleHttp\Psr7\Response;
|
|
|
+use Illuminate\Database\Schema\Blueprint;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
+use Tests\Support\AgentTestEnvironment;
|
|
|
+
|
|
|
+/** 代理人民币充值:总后台配置的通道、三方回调入账到代理钱包、人工充值流程。 */
|
|
|
+class AgentPaymentOrderTest extends TestCase
|
|
|
+{
|
|
|
+ use AgentTestEnvironment;
|
|
|
+
|
|
|
+ private AgentPaymentService $payments;
|
|
|
+
|
|
|
+ private function payments(): void
|
|
|
+ {
|
|
|
+ Schema::create('recharge_channel', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->integer('from'); $t->integer('data_type'); $t->string('key');
|
|
|
+ $t->string('name'); $t->string('type'); $t->decimal('rate', 8, 4);
|
|
|
+ $t->decimal('min')->nullable(); $t->decimal('max')->nullable(); $t->string('fixed')->nullable();
|
|
|
+ $t->integer('status'); $t->integer('sort'); $t->timestamps();
|
|
|
+ });
|
|
|
+ Schema::create('recharge_channel_group', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->string('name'); $t->text('recharge_type')->nullable();
|
|
|
+ $t->text('withdraw_type')->nullable(); $t->text('activity_type')->nullable(); $t->timestamps();
|
|
|
+ });
|
|
|
+ $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
|
|
|
+ DB::table('recharge_channel')->insert($fixture['channels']);
|
|
|
+ DB::table('recharge_channel_group')->insert($fixture['groups']);
|
|
|
+ foreach (['2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php',
|
|
|
+ '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php',
|
|
|
+ '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php',
|
|
|
+ '2026_11_08_120000_create_agent_payment_orders.php'] as $file) {
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
|
|
|
+ }
|
|
|
+ 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();
|
|
|
+ $history = $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-1', 'url' => '']]);
|
|
|
+ $result = $this->payments->createOnline($agent, '100.50', 'JDpay');
|
|
|
+ $order = $result['order'];
|
|
|
+ $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();
|
|
|
+ Schema::drop('payment_orders');
|
|
|
+ Schema::create('payment_orders', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->tinyInteger('type'); $t->string('order_no')->unique(); $t->string('member_id');
|
|
|
+ $t->decimal('amount', 18, 2); $t->decimal('fee', 18, 4)->default(0); $t->string('channel')->nullable();
|
|
|
+ $t->string('bank_name')->nullable(); $t->integer('payment_type')->nullable(); $t->tinyInteger('status')->default(0);
|
|
|
+ $t->string('state')->default('0'); $t->string('callback_url')->nullable(); $t->text('callback_data')->nullable();
|
|
|
+ $t->string('pay_no')->nullable(); $t->text('pay_url')->nullable(); $t->text('pay_data')->nullable();
|
|
|
+ $t->string('image')->nullable(); $t->tinyInteger('is_send')->default(0); $t->string('remark')->nullable();
|
|
|
+ $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
|
|
|
+ });
|
|
|
+ $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(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_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);
|
|
|
+ }
|
|
|
+}
|