instance('config',new Repository(['app'=>['timezone'=>'Asia/Shanghai'],'third_game'=>['currency'=>'CNY']])); Facade::setFacadeApplication($app); $db=new Manager($app); $db->addConnection(['driver'=>'sqlite','database'=>':memory:','prefix'=>'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db',$db->getDatabaseManager()); $app->bind('db.schema',fn()=>$db->schema()); $db->schema()->create('users',function(Blueprint $t){ $t->id(); $t->string('member_id'); $t->string('user_code'); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1); $t->timestamps(); $t->string('user_id')->nullable(); $t->string('first_name')->nullable(); $t->string('username')->nullable(); $t->string('language')->nullable(); $t->integer('recharge_channel_group_id')->default(0); }); $db->schema()->create('share_settings',function(Blueprint $t){ $t->id(); $t->boolean('enabled')->default(1); $t->boolean('benefit_enabled')->default(0); $t->string('share_domain')->default('https://pc.example'); $t->decimal('valid_member_min_deposit',24,10)->default(20); $t->timestamps(); }); $db->schema()->create('balance_logs',function(Blueprint $t){ $t->id(); $t->string('member_id'); $t->integer('type')->default(1); $t->string('change_type'); $t->decimal('amount',24,10); $t->timestamps(); }); $db->schema()->create('wallets',function(Blueprint $t){$t->id();$t->string('member_id');$t->decimal('available_balance',24,10)->default(50);$t->decimal('frozen_balance',24,10)->default(0);}); (require dirname(__DIR__,2).'/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up(); foreach ([[1,'A',null],[2,'B','A'],[3,'C','B'],[4,'D','C'],[5,'E','A'],[6,'X',null],[7,'DEMO',null]] as [$id,$code,$parent]) { DB::table('users')->insert(['id'=>$id,'member_id'=>'M'.$id,'user_code'=>$code,'agent_user_code'=>$parent,'from'=>$id===7?2:1,'created_at'=>'2026-10-01 00:00:00']); DB::table('balance_logs')->insert(['member_id'=>'M'.$id,'change_type'=>'充值','amount'=>20,'created_at'=>'2026-10-20 00:00:00']); DB::table('wallets')->insert(['member_id'=>'M'.$id]); } DB::table('share_settings')->insert(['activation_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_effective_bet'=>100]); DB::table('share_policies')->insert(['effective_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1, 'valid_member_min_deposit'=>20,'valid_member_min_effective_bet'=>100,'rules'=>json_encode(ShareDefaults::rules())]); $this->relations=new ShareRelations(); $this->relations->initialize(true); $this->service=new ShareSettlementService(); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function fact(int $user,string $amount,string $date='2026-10-24',array $extra=[]): int { return DB::table('share_bet_facts')->insertGetId($extra+['source'=>'fixture','source_id'=>uniqid('',true),'user_id'=>$user,'member_id'=>'M'.$user, 'category'=>'third:2','bet_at'=>$date.' 12:00:00','bet_date'=>$date,'settled_at'=>'2026-10-25 10:00:00', 'bet_amount'=>$amount,'net_profit'=>'-'.$amount,'effective_amount'=>$amount,'settled'=>1,'voided'=>0,'currency'=>'CNY','source_version'=>str_repeat('a',64)]); } private function amount(int $user,string $field='earned_amount'): string { return bcadd((string)DB::table('share_accounts')->where('user_id',$user)->value($field),'0',10); } public function test_nine_categories_have_the_exact_twelve_tiers_and_both_qualifiers_are_required(): void { $this->assertCount(9,ShareDefaults::rules()); $rules=ShareDefaults::rules()['third:2']; $this->assertCount(12,$rules); $this->assertSame('0.25',ShareSettlementService::rate($rules,1,'1000000')); $this->assertSame('0',ShareSettlementService::rate($rules,0,'99.99')); $this->assertSame('0.10',ShareSettlementService::rate($rules,0,'100')); $this->assertSame('0.70',ShareSettlementService::rate($rules,5,'1700000')); } public function test_receiver_own_rate_is_compounded_by_depth_and_retries_are_idempotent(): void { $this->fact(2,'200'); $this->fact(3,'20000'); $projection=$this->service->projection('2026-10-24'); $a=$projection['rows'][1]['third:2']; $b=$projection['rows'][2]['third:2']; $this->assertEquals(0.1,$a['rate']); $this->assertEquals(0.2,$b['rate']); $this->assertSame('0.0200000000',$a['indirect_commission']); $this->assertSame('40.0000000000',$b['direct_commission']); $before=DB::table('wallets')->get()->toJson(); $first=$this->service->settle('2026-10-24'); $this->assertSame(2,$first['receivers']); $this->assertSame('0.2200000000',$this->amount(1)); $this->assertSame('40.0000000000',$this->amount(2)); $entries=DB::table('share_ledger')->count(); $this->assertSame(0,$this->service->settle('2026-10-24')['changed']); $this->assertSame($entries,DB::table('share_ledger')->count()); $this->assertSame($before,DB::table('wallets')->get()->toJson()); } public function test_third_depth_adds_another_receiver_rate_factor(): void { $this->fact(2,'200'); $this->fact(3,'20000'); $this->fact(4,'1000'); $row=$this->service->projection('2026-10-24')['rows'][1]['third:2']; $this->assertSame('21000.0000000000',$row['indirect_performance']); $this->assertSame('0.0200010000',$row['indirect_commission']); $this->assertSame(3,DB::table('share_tree')->where('ancestor_user_id',1)->where('descendant_user_id',4)->value('depth')); } public function test_ten_percent_compounds_through_four_levels_using_only_the_receiver_rate(): void { DB::table('users')->insert(['id'=>8,'member_id'=>'M8','user_code'=>'F','agent_user_code'=>'D','created_at'=>'2026-10-01 00:00:00']); DB::table('balance_logs')->insert(['member_id'=>'M8','change_type'=>'充值','amount'=>20,'created_at'=>'2026-10-20 00:00:00']); $this->relations->initialize(true); $rules=ShareDefaults::rules(); $rules['third:2']=[ ['min_valid_members'=>0,'min_performance'=>'0','rate'=>'10'], ['min_valid_members'=>0,'min_performance'=>'2000','rate'=>'20'], ]; DB::table('share_policies')->where('effective_date','2026-10-24')->update(['rules'=>json_encode($rules)]); $this->fact(2,'1000'); $this->fact(3,'2000'); $this->fact(4,'1000'); $this->fact(8,'1000'); $rows=$this->service->projection('2026-10-24')['rows']; $a=$rows[1]['third:2']; $this->assertSame('10',$a['rate']); $this->assertSame('20',$rows[2]['third:2']['rate']); $this->assertSame('100.0000000000',$a['direct_commission']); $this->assertSame('4000.0000000000',$a['indirect_performance']); // 2000*10%*10% + 1000*10%^3 + 1000*10%^4 = 20+1+0.1. $this->assertSame('21.1000000000',$a['indirect_commission']); $this->service->settle('2026-10-24'); $this->assertSame('121.1000000000',$this->amount(1)); $this->assertSame(0,$this->service->settle('2026-10-24')['changed']); } public function test_depth_buckets_keep_precision_until_the_final_aggregate(): void { $second=ShareSettlementService::depthCommission('0.000000006','10',2); $third=ShareSettlementService::depthCommission('0.00000006','10',3); $this->assertSame('0.000000000060000000000000000000',$second); $this->assertSame('0.000000000060000000000000000000',$third); $this->assertSame('0.0000000001',bcadd(bcadd($second,$third,30),'0',10)); $this->assertSame('0.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','0',99)); $this->assertSame('1000.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','100',999)); $this->assertSame('0.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','0.1',999)); } public function test_formula_cutover_preserves_booked_history_and_changes_only_following_days(): void { $this->fact(2,'200'); $this->fact(3,'20000'); DB::table('share_policies')->where('effective_date','2026-10-24')->update(['indirect_bonus_rate'=>30]); DB::table('share_system_state')->insert(['key'=>'commission_depth_rate_start','value'=>'2099-01-01']); $this->service->settle('2026-10-24'); $this->assertSame('6.2000000000',$this->amount(1)); DB::table('share_system_state')->where('key','commission_depth_rate_start')->delete(); $before=DB::table('share_accounts')->get()->toJson(); $ledger=DB::table('share_ledger')->get()->toJson(); $wallets=DB::table('wallets')->get()->toJson(); $migration=require dirname(__DIR__,2).'/database/migrations/2026_10_24_150000_use_depth_rates_for_member_sharing.php'; $migration->up(); $this->assertSame('2026-10-26',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value')); $this->assertSame($before,DB::table('share_accounts')->get()->toJson()); $this->assertSame($ledger,DB::table('share_ledger')->get()->toJson()); $this->assertSame($wallets,DB::table('wallets')->get()->toJson()); $this->assertSame(0,$this->service->settle('2026-10-24')['changed']); $this->fact(2,'200','2026-10-26'); $this->fact(3,'20000','2026-10-26'); $this->assertSame('0.0200000000',$this->service->projection('2026-10-26')['rows'][1]['third:2']['indirect_commission']); Carbon::setTestNow(Carbon::parse('2026-10-27 12:00:00','Asia/Shanghai')); $migration->up(); $this->assertSame('2026-10-26',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value')); } public function test_fresh_installation_requires_no_legacy_bonus_or_waiting_day(): void { DB::table('share_settings')->update(['activation_date'=>null]); $migration=require dirname(__DIR__,2).'/database/migrations/2026_10_24_150000_use_depth_rates_for_member_sharing.php'; $migration->up(); $this->assertSame('1970-01-01',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value')); $migration->down(); $this->assertFalse(DB::table('share_system_state')->where('key','commission_depth_rate_start')->exists()); } public function test_qualification_excludes_performance_and_later_deposits_do_not_requalify_previous_days(): void { $this->fact(2,'200'); $this->fact(3,'20000'); DB::table('balance_logs')->where('member_id','M3')->update(['created_at'=>'2026-10-25 00:01:00']); $projection=$this->service->projection('2026-10-24'); $this->assertArrayNotHasKey(2,$projection['rows']); $this->assertSame('0',$projection['rows'][1]['third:2']['indirect_performance']); $this->assertSame(1,$projection['rows'][1]['third:2']['valid_direct_members']); $this->fact(2,'10','2026-10-25'); $this->fact(3,'20000','2026-10-25'); $this->assertArrayHasKey(2,$this->service->projection('2026-10-25')['rows']); } public function test_all_game_cumulative_qualification_but_category_tiers_stay_independent(): void { $this->fact(2,'50'); $this->fact(2,'60','2026-10-23',['category'=>'self:sport']); $this->fact(5,'20000','2026-10-24',['category'=>'self:lottery']); $rows=$this->service->projection('2026-10-24')['rows'][1]; $this->assertSame('50.0000000000',$rows['third:2']['direct_performance']); $this->assertSame('0',$rows['third:2']['rate']); $this->assertSame('0.20',$rows['self:lottery']['rate']); $this->assertSame(2,$rows['self:lottery']['valid_direct_members']); } public function test_late_result_reconciles_original_day_and_later_cumulative_qualifications(): void { $this->fact(2,'200'); $this->fact(3,'99'); $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1)); $this->fact(3,'1','2026-10-23'); DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-23"]']); $result=$this->service->reconcile(); $this->assertSame(2,$result['dates']); $this->assertSame('0.2000990000',$this->amount(1)); $this->assertSame('[]',DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value')); } public function test_paid_reversal_offsets_future_earnings_without_touching_main_wallet_or_reserved_claim(): void { $this->fact(2,'200'); $c=$this->fact(3,'20000'); $this->service->settle('2026-10-24'); DB::table('share_accounts')->where('user_id',1)->update(['paid_amount'=>'0.22']); $wallet=DB::table('wallets')->get()->toJson(); DB::table('share_bet_facts')->where('id',$c)->update(['voided'=>1,'effective_amount'=>0,'source_version'=>'canceled']); $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1)); $this->assertSame('0.2200000000',$this->amount(1,'paid_amount')); $this->assertSame('0.0000000000',$this->amount(2)); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00','Asia/Shanghai')); $this->fact(2,'10000','2026-10-25'); $this->service->settle('2026-10-25'); $this->assertSame('10.2000000000',$this->amount(1)); $this->assertSame('9.9800000000',bcsub($this->amount(1),$this->amount(1,'paid_amount'),10)); $this->assertSame($wallet,DB::table('wallets')->get()->toJson()); } public function test_policy_snapshot_is_used_for_replay_and_preview_is_read_only(): void { $fact=$this->fact(2,'200'); $this->service->settle('2026-10-24'); $rules=ShareDefaults::rules(); $rules['third:2']=[['min_valid_members'=>0,'min_performance'=>'0','rate'=>'99']]; DB::table('share_policies')->insert(['effective_date'=>'2026-10-25','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_deposit'=>20, 'valid_member_min_effective_bet'=>100,'rules'=>json_encode($rules)]); DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>'300']); $before=DB::table('share_accounts')->get()->toJson(); $this->assertSame('0.1000000000',$this->service->settle('2026-10-24',false)['delta']); $this->assertSame($before,DB::table('share_accounts')->get()->toJson()); $this->service->settle('2026-10-24'); $this->assertSame('0.3000000000',$this->amount(1)); } public function test_grades_use_incremental_thresholds_and_never_downgrade_on_correction(): void { DB::table('share_levels')->insert([['level'=>2,'name'=>'二级','incremental_commission'=>100],['level'=>3,'name'=>'三级','incremental_commission'=>300]]); $fact=$this->fact(2,'50000'); $this->service->settle('2026-10-24'); $this->assertSame(2,DB::table('share_members')->where('user_id',1)->value('level')); DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>160000]); $this->service->settle('2026-10-24'); $this->assertSame('400.0000000000',$this->amount(1)); $this->assertSame(3,DB::table('share_members')->where('user_id',1)->value('level')); DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>200]); $this->service->settle('2026-10-24'); $this->assertSame(3,DB::table('share_members')->where('user_id',1)->value('level')); } public function test_later_first_binding_keeps_subtree_but_does_not_give_old_bets_to_new_ancestor(): void { $this->fact(2,'200'); $this->fact(3,'20000'); $this->relations->bind(1,6,'A'); $this->assertSame(3,DB::table('share_tree')->where('ancestor_user_id',6)->where('descendant_user_id',3)->value('depth')); $this->assertArrayNotHasKey(6,$this->service->projection('2026-10-24')['rows']); $this->expectException(\RuntimeException::class); $this->relations->bind(6,3,'X'); } public function test_invalid_legacy_codes_and_cycles_are_isolated_without_guessing_or_mutating_users(): void { foreach ([[8,'P','Q'],[9,'Q','P'],[10,'R','MISSING'],[11,'DUP',null],[12,'DUP',null],[13,'CHILD','DUP']] as [$id,$code,$parent]) { DB::table('users')->insert(['id'=>$id,'member_id'=>'M'.$id,'user_code'=>$code,'agent_user_code'=>$parent,'created_at'=>'2026-10-01 00:00:00']); } $users=DB::table('users')->get()->toJson(); $preview=$this->relations->initialize(); $this->assertSame(6,$preview['created']); $this->assertSame(7,DB::table('share_members')->count()); $this->relations->initialize(true); $this->assertSame('cycle',DB::table('share_members')->where('user_id',8)->value('binding_status')); $this->assertNull(DB::table('share_members')->where('user_id',9)->value('parent_user_id')); $this->assertSame('missing',DB::table('share_members')->where('user_id',10)->value('binding_status')); $this->assertSame('ambiguous',DB::table('share_members')->where('user_id',13)->value('binding_status')); $this->assertSame($users,DB::table('users')->get()->toJson()); $this->assertSame(0,$this->relations->initialize(true)['created']); } public function test_fault_rolls_back_every_ledger_account_and_period_write(): void { $this->fact(2,'200'); DB::statement("CREATE TRIGGER reject_share_ledger BEFORE INSERT ON bot_share_ledger BEGIN SELECT RAISE(ABORT,'injected failure'); END"); try { $this->service->settle('2026-10-24'); $this->fail('ledger failure expected'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('injected failure',$e->getMessage()); } $this->assertSame('0.0000000000',$this->amount(1)); $this->assertSame(0,DB::table('share_settlements')->count()); $this->assertSame(0,DB::table('share_ledger')->count()); $this->assertSame(0,DB::transactionLevel()); DB::statement('DROP TRIGGER reject_share_ledger'); $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1)); } public function test_today_is_rejected_without_debug_opt_in_and_foreign_currency_is_not_combined(): void { $this->fact(2,'200'); $this->fact(3,'20000','2026-10-24',['currency'=>'USD']); $this->assertSame('0',$this->service->projection('2026-10-24')['rows'][1]['third:2']['indirect_performance']); $this->expectException(\InvalidArgumentException::class); $this->service->settle('2026-10-25'); } private function command(array $options): CommandTester { $command = new SettleSharing(); $command->setLaravel(app()); $tester = new CommandTester($command); $tester->execute($options); return $tester; } public function test_debug_today_credits_once_revises_only_the_delta_and_closes_normally_after_midnight(): void { $this->service->settle('2026-10-24'); $fact = $this->fact(2, '200', '2026-10-25'); $wallets = DB::table('wallets')->get()->toJson(); $first = $this->service->settle('2026-10-25', true, true); $this->assertTrue($first['written']); $this->assertSame('0.2000000000', $this->amount(1)); $this->assertSame('2026-10-24', DB::table('share_system_state')->where('key', 'last_settlement_date')->value('value')); $this->assertContains('2026-10-25', json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true)); $entries = DB::table('share_ledger')->count(); $this->assertSame(0, $this->service->settle('2026-10-25', true, true)['changed']); $this->assertSame($entries, DB::table('share_ledger')->count()); DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => 400]); $this->assertSame('0.2000000000', $this->service->settle('2026-10-25', true, true)['delta']); $this->assertSame('0.4000000000', $this->amount(1)); $this->assertSame(0, $this->service->reconcile()['dates']); Carbon::setTestNow(Carbon::parse('2026-10-26 00:01:00', 'Asia/Shanghai')); $entries = DB::table('share_ledger')->count(); $this->assertSame(1, $this->service->reconcile()['dates']); $this->assertSame('2026-10-25', DB::table('share_system_state')->where('key', 'last_settlement_date')->value('value')); $this->assertSame($entries, DB::table('share_ledger')->count()); $this->assertSame('0.4000000000', $this->amount(1)); $this->assertSame($wallets, DB::table('wallets')->get()->toJson()); } public function test_allow_today_flag_defaults_to_today_and_ordinary_command_still_defaults_to_yesterday(): void { $this->fact(2, '200'); $this->fact(2, '300', '2026-10-25'); $normal = $this->command([]); $this->assertSame(0, $normal->getStatusCode()); $this->assertSame('2026-10-24', json_decode($normal->getDisplay(), true)['date']); $this->assertSame('0.2000000000', $this->amount(1)); $blocked = $this->command(['date' => '2026-10-25']); $this->assertSame(1, $blocked->getStatusCode()); $this->assertStringContainsString('--allow-today', $blocked->getDisplay()); $debug = $this->command(['--allow-today' => true]); $this->assertSame(0, $debug->getStatusCode()); $this->assertSame('2026-10-25', json_decode($debug->getDisplay(), true)['date']); $this->assertSame('0.5000000000', $this->amount(1)); $again = $this->command(['date' => '2026-10-25', '--allow-today' => true]); $this->assertSame(0, $again->getStatusCode()); $this->assertSame(0, json_decode($again->getDisplay(), true)['changed']); $this->assertSame('2026-10-24', DB::table('share_system_state')->where('key', 'last_settlement_date')->value('value')); } public function test_debug_preview_today_is_read_only_including_levels_markers_and_reconciliation_queue(): void { $this->fact(2, '50000', '2026-10-25'); DB::table('share_levels')->insert(['level' => 2, 'name' => '二级', 'incremental_commission' => 100]); $tables = ['share_accounts', 'share_settlements', 'share_ledger', 'share_members', 'share_system_state', 'wallets']; $before = []; foreach ($tables as $table) $before[$table] = DB::table($table)->get()->toJson(); $preview = $this->command(['--allow-today' => true, '--preview' => true]); $this->assertSame(0, $preview->getStatusCode()); $data = json_decode($preview->getDisplay(), true); $this->assertSame('2026-10-25', $data['date']); $this->assertFalse($data['written']); $this->assertGreaterThan(0, $data['changed']); foreach ($tables as $table) $this->assertSame($before[$table], DB::table($table)->get()->toJson(), $table); } /** @dataProvider forbiddenDebugOptions */ public function test_future_dates_and_incompatible_debug_options_are_rejected_before_writing(array $options): void { $this->fact(2, '200', '2026-10-25'); $before = DB::table('share_system_state')->get()->toJson(); $command = $this->command($options); $this->assertSame(1, $command->getStatusCode()); $this->assertSame(0, DB::table('share_ledger')->count()); $this->assertSame(0, DB::table('share_settlements')->count()); $this->assertSame('0.0000000000', $this->amount(1)); $this->assertSame($before, DB::table('share_system_state')->get()->toJson()); } public static function forbiddenDebugOptions(): array { return [ 'future' => [['date' => '2026-10-26']], 'future with opt in' => [['date' => '2026-10-26', '--allow-today' => true]], 'future preview' => [['date' => '2026-10-26', '--allow-today' => true, '--preview' => true]], 'invalid calendar date' => [['date' => '2026-02-30', '--allow-today' => true]], 'reconcile' => [['--allow-today' => true, '--reconcile' => true]], 'reconcile and preview' => [['--allow-today' => true, '--reconcile' => true, '--preview' => true]], 'diagnose and reconcile' => [['--diagnose' => true, '--reconcile' => true]], ]; } public function test_debug_today_uses_beijing_date_even_when_utc_is_still_yesterday(): void { Carbon::setTestNow(Carbon::parse('2026-10-25 00:01:00', 'Asia/Shanghai')); $command = $this->command(['--allow-today' => true]); $this->assertSame(0, $command->getStatusCode()); $this->assertSame('2026-10-25', json_decode($command->getDisplay(), true)['date']); $this->assertFalse(DB::table('share_system_state')->where('key', 'last_settlement_date')->exists()); } public function test_diagnose_reports_unavailable_policy_without_using_retired_activation_or_changing_state(): void { $this->fact(2, '200', '2026-10-25'); DB::table('share_settings')->update(['activation_date' => '2026-10-26']); DB::table('share_policies')->where('effective_date','<>','2026-10-24')->delete(); DB::table('share_policies')->update(['effective_date' => '2026-10-26']); $tables=['share_accounts','share_ledger','share_settlements','share_system_state','share_members','wallets']; $before=[]; foreach ($tables as $table) $before[$table]=DB::table($table)->get()->toJson(); $command=$this->command(['date'=>'2026-10-25','--allow-today'=>true,'--diagnose'=>true]); $this->assertSame(0,$command->getStatusCode()); $data=json_decode($command->getDisplay(),true); $this->assertFalse($data['written']); $this->assertArrayNotHasKey('activation_date',$data); $this->assertSame('2026-10-26',$data['next_policy_effective_date']); $this->assertNull($data['policy']); $this->assertSame(['no_effective_policy'],array_column($data['blockers'],'code')); $this->assertSame(1,$data['facts']['candidates']); $this->assertSame(0,$data['receivers']); foreach ($tables as $table) $this->assertSame($before[$table],DB::table($table)->get()->toJson(),$table); } public function test_diagnose_counts_pending_voided_foreign_and_zero_effective_facts_and_uses_actual_projection_totals(): void { $this->fact(2,'200','2026-10-25'); $this->fact(2,'200','2026-10-25',['settled'=>0]); $this->fact(2,'200','2026-10-25',['voided'=>1]); $this->fact(2,'200','2026-10-25',['currency'=>'USD']); $this->fact(2,'200','2026-10-25',['effective_amount'=>0]); $data=$this->service->diagnose('2026-10-25'); $this->assertSame(['total'=>5,'unsettled'=>1,'voided'=>1,'currency_mismatch'=>1,'zero_effective'=>1,'candidates'=>1],$data['facts']); $this->assertSame($data['facts'],$data['facts_by_source']['fixture']); $this->assertSame([],$data['blockers']); $this->assertSame(1,$data['receivers']); $this->assertSame(1,$data['positive_receivers']); $this->assertSame('0.2000000000',$data['projected_commission']); $this->assertSame(0,DB::table('share_ledger')->count()); } /** @dataProvider retiredGates */ public function test_complete_policy_settles_immediately_without_legacy_switch_or_activation_date($activation,int $benefit): void { DB::table('share_settings')->update(['activation_date'=>$activation,'benefit_enabled'=>$benefit]); DB::table('share_policies')->update(['benefit_enabled'=>$benefit]); $this->fact(2,'200','2026-10-25'); $diagnosis=$this->service->diagnose('2026-10-25'); $this->assertSame([],$diagnosis['blockers']); $this->assertArrayNotHasKey('activation_date',$diagnosis); $this->assertArrayNotHasKey('benefit_enabled',$diagnosis['policy']); $this->assertSame(1,$diagnosis['receivers']); $this->assertSame(1,$this->service->settle('2026-10-25',true,true)['receivers']); $this->assertSame('0.2000000000',$this->amount(1)); $this->assertSame(0,$this->service->settle('2026-10-25',true,true)['changed']); } public static function retiredGates(): array { return [[null,0],['2099-01-01',0],[null,1],['2099-01-01',1]]; } public function test_reconcile_has_no_initial_activation_cutoff_and_incomplete_threshold_is_still_reported(): void { DB::table('share_settings')->update(['activation_date'=>null,'benefit_enabled'=>0]); DB::table('share_policies')->update(['benefit_enabled'=>0]); $this->fact(2,'200'); DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-24"]']); $this->assertSame(1,$this->service->reconcile()['changed']); $this->assertSame('0.2000000000',$this->amount(1)); DB::table('share_policies')->update(['valid_member_min_effective_bet'=>null]); $diagnosis=$this->service->diagnose('2026-10-25'); $this->assertSame(['missing_effective_bet_threshold'],array_column($diagnosis['blockers'],'code')); } /** @dataProvider diagnosticExclusions */ public function test_diagnose_explains_the_same_member_and_relationship_filters_as_settlement(string $reason): void { $this->fact(2,'200','2026-10-25'); switch ($reason) { case 'deposit_below_threshold': DB::table('balance_logs')->where('member_id','M2')->delete(); break; case 'effective_bet_below_threshold': DB::table('share_bet_facts')->update(['effective_amount'=>50]); break; case 'no_ancestor_relationship': DB::table('share_tree')->where('descendant_user_id',2)->where('depth','>',0)->delete(); break; case 'relationship_bound_after_bet': DB::table('share_tree')->where('descendant_user_id',2)->where('depth','>',0)->update(['bound_at'=>'2026-10-25 12:00:01']); break; case 'bettor_unavailable': DB::table('users')->where('id',2)->update(['from'=>2]); break; case 'unsupported_category': DB::table('share_bet_facts')->update(['category'=>'unknown']); break; case 'receiver_unavailable': DB::table('users')->where('id',1)->update(['from'=>2]); break; } $data=$this->service->diagnose('2026-10-25'); $this->assertSame(1,$data['facts']['candidates']); $this->assertSame(1,$data['excluded'][$reason]); $this->assertSame(0,$data['receivers']); $this->assertSame(0,count($this->service->projection('2026-10-25')['rows'])); $this->assertSame('0.0000000000',$data['projected_commission']); } public static function diagnosticExclusions(): array { return array_map(fn($reason)=>[$reason],['deposit_below_threshold','effective_bet_below_threshold', 'no_ancestor_relationship','relationship_bound_after_bet','bettor_unavailable','unsupported_category','receiver_unavailable']); } public function test_open_day_stays_queued_and_a_missed_midnight_run_is_automatically_recovered(): void { Carbon::setTestNow(Carbon::parse('2026-10-24 12:00:00','Asia/Shanghai')); $this->fact(2,'200'); DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-24"]']); $this->assertSame(0,$this->service->reconcile()['dates']); $this->assertSame(['2026-10-24'],json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'),true)); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00','Asia/Shanghai')); $this->service->settle('2026-10-25'); $this->assertSame(1,$this->service->reconcile()['dates']); $this->assertSame('0.2000000000',$this->amount(1)); } public function test_real_members_without_an_invitation_code_receive_one_once_without_rebinding(): void { DB::table('users')->insert(['id'=>8,'member_id'=>'M8','user_code'=>'','agent_user_code'=>null,'from'=>-1,'created_at'=>'2026-10-01 00:00:00']); $this->assertSame(1,$this->relations->initialize()['invitation_codes_assigned']); $this->assertSame('',DB::table('users')->where('id',8)->value('user_code')); $this->relations->initialize(true); $code=DB::table('users')->where('id',8)->value('user_code'); $this->assertNotSame('',$code); $this->assertNotSame('0',$code); $this->assertSame($code,DB::table('share_members')->where('user_id',8)->value('invitation_code')); $this->assertNull(DB::table('share_members')->where('user_id',8)->value('parent_user_id')); $this->relations->initialize(true); $this->assertSame($code,DB::table('users')->where('id',8)->value('user_code')); } public function test_entry_visibility_does_not_control_monetary_policy_and_pre_activation_bets_never_pay(): void { $this->fact(2,'200'); DB::table('share_policies')->where('effective_date','2026-10-24')->update(['enabled'=>0]); $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1)); $this->assertSame([],$this->service->projection('2026-10-23')['rows']); $this->assertSame(0,$this->service->settle('2026-10-23')['changed']); } public function test_new_telegram_member_after_initialization_gets_a_root_and_account_without_reinitializing_others(): void { if (!function_exists('removeZero')) require_once dirname(__DIR__,2).'/app/Helpers/helpers.php'; DB::table('wallets')->insert(['member_id'=>'M99']); $user=\App\Services\PublicService::index('M99','telegram-new','新会员'); $id=(int)$user->id; $this->assertSame(-1,(int)$user->from); $this->assertSame(1,(int)$user->recharge_channel_group_id); $this->assertSame('root',DB::table('share_members')->where('user_id',$id)->value('binding_status')); $this->assertSame((string)$user->user_code,DB::table('share_members')->where('user_id',$id)->value('invitation_code')); $this->assertSame(0,DB::table('share_tree')->where('ancestor_user_id',$id)->where('descendant_user_id',$id)->value('depth')); $this->assertSame('0.0000000000',$this->amount($id)); $this->assertSame(8,DB::table('share_members')->count()); } }