wallets = new AgentWalletService(); $this->ledger = new AgentProfitLedgerService($this->wallets, $assignments); $this->recharges = new AgentRechargeService($this->wallets, new AgentEquityService($this->wallets)); $this->plans = new AgentPlanApplicationService($assignments, $this->wallets); DB::table('config')->insert([['field' => 'receiving_type', 'val' => '2'], ['field' => 'exchange_rate_rmb', 'val' => '7'], ['field' => 'exchange_rate_difference', 'val' => '0.5'], ['field' => 'receiving_address', 'val' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE']]); } private function profile(string $margin = '7000', string $rate = '10'): AgentProfitCommissionProfile { $values = ['name' => 'Plan ' . $margin, 'margin_amount' => $margin]; foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) $values[$field] = $rate; return AgentProfitCommissionProfile::query()->create($values); } /** 合营代理:申请方案并审核通过,再充值 $usdt USDT(汇率 7)。 */ private function jointAgent(AgentProfitCommissionProfile $profile, string $usdt): Agent { $agent = $this->agent(); $application = $this->plans->apply($agent, (int) $profile->id); $this->plans->audit((int) $application->id, 'approved', 1); $this->recharge($agent, $usdt); return $agent->fresh(); } private function recharge(Agent $agent, string $usdt): void { $record = $this->recharges->submitProof($agent, ['net' => 'trc20', 'usdt_amount' => $usdt, 'to_address' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'image' => 'https://example.test/proof.png']); $this->recharges->audit((int) $record->id, AgentRecharge::STATUS_SUCCESS, 1); } private function member(Agent $agent): int { static $number = 0; return (int) DB::table('users')->insertGetId(['member_id' => 'jv' . ++$number, 'username' => 'jv' . $number, 'agent_id' => $agent->id, 'created_at' => now()]); } private function fact(int $userId, string $net, string $category = 'self:lottery', string $settledAt = '2026-09-07 13:00:00'): object { $id = DB::table('share_bet_facts')->insertGetId(['source' => 'test', 'source_id' => uniqid('', true), 'user_id' => $userId, 'category' => $category, 'bet_at' => $settledAt, 'bet_date' => substr($settledAt, 0, 10), 'settled_at' => $settledAt, 'bet_amount' => 100, 'effective_amount' => 100, 'net_profit' => $net, 'settled' => 1, 'voided' => 0, 'created_at' => now(), 'updated_at' => now()]); return DB::table('share_bet_facts')->find($id); } private function process(object $fact, Agent $agent): string { return $this->ledger->process($fact, (int) $agent->id); } public function test_recharge_becomes_margin_and_activates_plan(): void { $this->services(); $agent = $this->jointAgent($this->profile('7000'), '999'); $this->assertSame('6993.0000', $agent->balance); $this->assertSame('6993.0000', $agent->margin_amount); $this->assertSame(0, $agent->profit_active, 'below the plan margin the plan stays inactive'); $this->recharge($agent, '1'); $agent = $agent->fresh(); $this->assertSame('7000.0000', $agent->margin_amount); $this->assertSame(1, $agent->profit_active); $this->assertSame('0.0000', $this->wallets->withdrawable($agent), 'margin is never withdrawable'); $this->assertSame('0', DB::table('recharges')->count() . '', 'agent recharges never land in member recharges'); } public function test_share_is_two_sided_and_daily_credit_keeps_realtime_invariant(): void { $this->services(); $agent = $this->jointAgent($this->profile('7000', '10'), '1000'); $userId = $this->member($agent); $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent)); $this->assertSame('counted', $this->process($this->fact($userId, '500', 'third:1'), $agent)); $agent = $agent->fresh(); $this->assertSame('6960.0000', $agent->realtime_balance); $this->assertSame('7000.0000', $agent->balance, 'balance only moves at the daily credit'); Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00')); $result = (new AgentCommissionService($this->wallets))->settle('2026-09-07'); $this->assertSame(['created' => 2, 'credited' => 2], $result); $agent = $agent->fresh(); $this->assertSame('6960.0000', $agent->balance); $this->assertSame('6960.0000', $agent->realtime_balance); $this->assertSame('-40.0000', bcadd((string) AgentRebateRecord::query()->sum('profit_commission'), '0', 4)); $this->assertSame(0, AgentProfitEntry::query()->whereNull('rebate_record_id')->count()); $this->assertSame(['created' => 0, 'credited' => 0], (new AgentCommissionService($this->wallets))->settle('2026-09-07')); } public function test_dropping_below_stop_percent_skips_later_bets_until_margin_is_refilled(): void { $this->services(); $agent = $this->jointAgent($this->profile('7000', '10'), '1000'); $userId = $this->member($agent); // Member wins 64000: agent share -6400, realtime margin 600 < 10% of 7000. $this->process($this->fact($userId, '64000'), $agent); $agent = $agent->fresh(); $this->assertSame('600.0000', $agent->realtime_balance); $this->assertSame(0, $agent->profit_active); $this->assertSame('skipped', $this->process($this->fact($userId, '-1000'), $agent)); $this->assertSame('600.0000', $agent->fresh()->realtime_balance); $this->recharge($agent, '900'); // +6300: realtime 6900 < 7000, still stopped $this->assertSame(0, $agent->fresh()->profit_active); $this->recharge($agent, '100'); // realtime 7600 >= 7000 $agent = $agent->fresh(); $this->assertSame(1, $agent->profit_active); $this->assertSame('14000.0000', $agent->margin_amount); $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent)); } public function test_loss_is_capped_at_zero_balance(): void { $this->services(); $agent = $this->jointAgent($this->profile('0', '50'), '10'); // 70 margin, no margin requirement $userId = $this->member($agent); $this->process($this->fact($userId, '1000'), $agent); $this->assertSame('0.0000', $agent->fresh()->realtime_balance); $this->assertSame('-70.0000', AgentProfitEntry::query()->value('share_amount')); } public function test_changed_payout_is_corrected_in_place_then_by_revision_after_credit(): void { $this->services(); $agent = $this->jointAgent($this->profile('7000', '10'), '1000'); $userId = $this->member($agent); $fact = $this->fact($userId, '-100'); $this->process($fact, $agent); DB::table('share_bet_facts')->where('id', $fact->id)->update(['net_profit' => -300]); $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent)); $this->assertSame(1, AgentProfitEntry::query()->count()); $this->assertSame('30.0000', AgentProfitEntry::query()->value('share_amount')); Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00')); (new AgentCommissionService($this->wallets))->settle('2026-09-07'); DB::table('share_bet_facts')->where('id', $fact->id)->update(['voided' => 1]); $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent)); $revision = AgentProfitEntry::query()->where('revision', 1)->firstOrFail(); $this->assertSame('-30.0000', $revision->share_amount); $this->assertNull($revision->rebate_record_id); $this->assertSame('7000.0000', $agent->fresh()->realtime_balance); } public function test_only_profit_above_margin_is_withdrawable_and_pending_losses_are_reserved(): void { $this->services(); $agent = $this->jointAgent($this->profile('7000', '10'), '1000'); $userId = $this->member($agent); Agent::query()->whereKey($agent->id)->update(['withdraw_password' => \Illuminate\Support\Facades\Hash::make('pw')]); $this->process($this->fact($userId, '-15000'), $agent); // +1500 Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00')); (new AgentCommissionService($this->wallets))->settle('2026-09-07'); $agent = $agent->fresh(); $this->assertSame('1500.0000', $this->wallets->withdrawable($agent)); // Today's pending loss (-500) reduces what may leave now. $this->process($this->fact($userId, '5000', 'self:lottery', '2026-09-08 01:00:00'), $agent); $agent = $agent->fresh(); $this->assertSame('1000.0000', $this->wallets->withdrawable($agent)); $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20', 'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]); $service = new AgentWithdrawalService($this->wallets); $withdrawal = $service->request($agent, $account->id, '100', 'pw', 'ok'); // 100 USDT * 7.5 $this->assertSame('750.0000', $withdrawal->amount); $this->assertSame('7.5000', $withdrawal->exchange_rate); $this->expectExceptionMessage('可提现盈利不足'); $service->request($agent->fresh(), $account->id, '100', 'pw', 'too-much'); } public function test_equity_order_waits_for_recharge_then_opens_without_review(): void { $this->services(); AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 70000, 'equity_min_percent' => 0.1, 'equity_max_percent' => 1]); $agent = $this->agent(Agent::TYPE_EQUITY); $equity = new AgentEquityService($this->wallets); $order = $equity->order($agent, '0.5'); $this->assertSame(AgentEquityOrder::STATUS_PENDING, $order->status); $this->assertSame('35000.0000', $order->amount); $this->recharge($agent, '5000'); $agent = $agent->fresh(); $this->assertSame('0.5000', $agent->equity_percent); $this->assertSame('0.0000', $agent->balance); $this->assertSame('0.0000', $agent->margin_amount, 'equity recharges are not margin'); $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $order->fresh()->status); $this->expectExceptionMessage('累计持股不能超过'); $equity->order($agent, '0.6'); } public function test_admin_create_ignores_removed_hierarchy_and_plan_fields(): void { $request = Request::create('/admin/agent/create', 'POST', ['username' => 'newagent', 'password' => 'secret1', 'type' => 2, 'parent_id' => 99, 'owner_user_id' => 5, 'flow_commission_profile_id' => 1, 'profit_commission_profile_id' => 1]); $request->user = (object) ['id' => 1]; $this->app->instance('request', $request); $data = (new AdminAgent())->store($request, $this->agents)->getData(true); $this->assertSame(0, $data['code'], (string) $data['msg']); $agent = Agent::query()->where('username', 'newagent')->firstOrFail(); $this->assertSame(Agent::TYPE_EQUITY, $agent->type); $this->assertNull($agent->parent_id); $this->assertNull($agent->owner_user_id); $this->assertNull($agent->profit_commission_profile_id); $this->assertSame('入股', $agent->type_text); $this->assertArrayNotHasKey('realtime_balance', $agent->toArray()); } public function test_member_daily_report_aggregates_bets_funds_and_agent_share(): void { $this->services(); $agent = $this->jointAgent($this->profile('0', '10'), '10'); $userId = $this->member($agent); $memberId = DB::table('users')->where('id', $userId)->value('member_id'); $this->process($this->fact($userId, '-100'), $agent); $this->fact($userId, '40', 'third:2'); DB::table('recharges')->insert(['member_id' => $memberId, 'status' => 1, 'amount' => 300, 'created_at' => '2026-09-07 09:00:00']); $service = new MemberDailyReportService(); $this->assertSame(1, $service->refresh('2026-09-07')); $row = DB::table('member_daily_reports')->first(); $this->assertSame('300.0000', bcadd((string) $row->deposit_amount, '0', 4)); $this->assertSame('-60.0000', bcadd((string) $row->win_loss, '0', 4)); $this->assertSame('10.0000', bcadd((string) $row->agent_share, '0', 4)); $this->assertSame(2, (int) $row->bet_count); $this->assertSame(1, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $agent)['total']); $this->assertSame(0, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $this->agent())['total']); } public function test_sync_reads_new_facts_once_and_ignores_non_joint_members(): void { $this->services(); $agent = $this->jointAgent($this->profile('0', '10'), '10'); $this->fact($this->member($agent), '-100'); $this->fact($this->member($this->agent(Agent::TYPE_EQUITY)), '-100'); $this->fact((int) DB::table('users')->insertGetId(['member_id' => 'plain', 'username' => 'plain']), '-100'); $first = $this->ledger->sync(); $this->assertSame(1, $first['counted']); $this->assertSame(0, $first['failed']); $this->assertSame(1, $this->ledger->sync()['unchanged'], 'overlap window re-reads the fact without double counting'); $this->assertSame('80.0000', $agent->fresh()->realtime_balance); } public function test_agent_with_a_plan_cannot_switch_and_can_reapply_after_rejection(): void { $this->services(); $first = $this->profile('7000'); $second = $this->profile('14000'); $agent = $this->agent(); $rejected = $this->plans->apply($agent, (int) $second->id); $this->plans->audit((int) $rejected->id, 'rejected', 1, 'no'); $this->plans->audit((int) $this->plans->apply($agent, (int) $first->id)->id, 'approved', 1); $this->assertSame('合营', $this->plans->paginate([])['list']->first()->agent->type_text); $this->expectExceptionMessage('已有分成方案,不能切换'); $this->plans->apply($agent->fresh(), (int) $second->id); } public function test_proof_must_target_platform_address(): void { $this->services(); $this->expectExceptionMessage('收款地址不是平台当前的收款地址'); $this->recharges->submitProof($this->agent(), ['net' => 'trc20', 'usdt_amount' => '10', 'to_address' => 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf', 'image' => 'https://example.test/p.png']); } public function test_correction_goes_to_the_agent_that_received_the_share(): void { $this->services(); $agent = $this->jointAgent($this->profile('7000', '10'), '1000'); $other = $this->jointAgent($this->profile('0', '10'), '10'); $userId = $this->member($agent); $fact = $this->fact($userId, '-100'); $this->ledger->sync(); DB::table('users')->where('id', $userId)->update(['agent_id' => $other->id]); DB::table('share_bet_facts')->where('id', $fact->id)->update(['net_profit' => -300, 'updated_at' => now()]); $this->assertSame(1, $this->ledger->sync()['corrected']); $this->assertSame('7030.0000', $agent->fresh()->realtime_balance); $this->assertSame('70.0000', $other->fresh()->realtime_balance); } public function test_debit_cannot_leave_an_earlier_pending_loss_uncovered(): void { $this->services(); $agent = $this->jointAgent($this->profile('0', '10'), '100'); // balance 700 $userId = $this->member($agent); $this->process($this->fact($userId, '6000', 'self:lottery', '2026-09-07 13:00:00'), $agent); // -600 yesterday $this->process($this->fact($userId, '-5000', 'self:lottery', '2026-09-08 01:00:00'), $agent); // +500 today $this->assertSame('600.0000', $agent->fresh()->realtime_balance); try { $this->wallets->adjust((int) $agent->id, '200', 'debit', 't', 'debit-1', 'admin', 1); $this->fail('crediting yesterday after the debit would leave a negative balance'); } catch (\RuntimeException $e) { $this->assertSame('代理余额不足', $e->getMessage()); } $this->wallets->adjust((int) $agent->id, '100', 'debit', 't', 'debit-2', 'admin', 1); Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00')); (new AgentCommissionService($this->wallets))->settle('2026-09-07'); $this->assertSame('0.0000', $agent->fresh()->balance); } public function test_commission_categories_drop_third_party_lottery(): void { $this->assertCount(8, ShareDefaults::commissionCategories()); $this->assertArrayNotHasKey('third:3', ShareDefaults::commissionCategories()); $this->assertSame(array_keys(ShareDefaults::commissionCategories()), array_keys(array_intersect_key( ShareDefaults::commissionCategories(), AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS))); $this->assertCount(8, AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS); $rules = json_decode(DB::table('share_policies')->value('rules'), true); $this->assertArrayNotHasKey('third:3', $rules, 'migration removes third-party lottery from share rules'); } }