SharingSettlementTest.php 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Console\Commands\SettleSharing;
  4. use App\Services\Sharing\ShareDefaults;
  5. use App\Services\Sharing\ShareRelations;
  6. use App\Services\Sharing\ShareSettlementService;
  7. use Carbon\Carbon;
  8. use Illuminate\Config\Repository;
  9. use Illuminate\Database\Capsule\Manager;
  10. use Illuminate\Database\Schema\Blueprint;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Support\Facades\DB;
  13. use Illuminate\Support\Facades\Facade;
  14. use PHPUnit\Framework\TestCase;
  15. use Symfony\Component\Console\Tester\CommandTester;
  16. class SharingSettlementTest extends TestCase
  17. {
  18. private ShareSettlementService $service;
  19. private ShareRelations $relations;
  20. protected function setUp(): void
  21. {
  22. Facade::clearResolvedInstances();
  23. Carbon::setTestNow(Carbon::parse('2026-10-25 12:00:00','Asia/Shanghai'));
  24. $app=new Application(dirname(__DIR__,2));
  25. $app->instance('config',new Repository(['app'=>['timezone'=>'Asia/Shanghai'],'third_game'=>['currency'=>'CNY']]));
  26. Facade::setFacadeApplication($app);
  27. $db=new Manager($app); $db->addConnection(['driver'=>'sqlite','database'=>':memory:','prefix'=>'bot_']);
  28. $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db',$db->getDatabaseManager()); $app->bind('db.schema',fn()=>$db->schema());
  29. $db->schema()->create('users',function(Blueprint $t){
  30. $t->id(); $t->string('member_id'); $t->string('user_code'); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1); $t->timestamps();
  31. $t->string('user_id')->nullable(); $t->string('first_name')->nullable(); $t->string('username')->nullable(); $t->string('language')->nullable();
  32. $t->integer('recharge_channel_group_id')->default(0);
  33. });
  34. $db->schema()->create('share_settings',function(Blueprint $t){
  35. $t->id(); $t->boolean('enabled')->default(1); $t->boolean('benefit_enabled')->default(0); $t->string('share_domain')->default('https://pc.example');
  36. $t->decimal('valid_member_min_deposit',24,10)->default(20); $t->timestamps();
  37. });
  38. $db->schema()->create('balance_logs',function(Blueprint $t){
  39. $t->id(); $t->string('member_id'); $t->integer('type')->default(1); $t->string('change_type'); $t->decimal('amount',24,10); $t->timestamps();
  40. });
  41. $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);});
  42. (require dirname(__DIR__,2).'/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up();
  43. 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]) {
  44. 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']);
  45. DB::table('balance_logs')->insert(['member_id'=>'M'.$id,'change_type'=>'充值','amount'=>20,'created_at'=>'2026-10-20 00:00:00']);
  46. DB::table('wallets')->insert(['member_id'=>'M'.$id]);
  47. }
  48. DB::table('share_settings')->insert(['activation_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_effective_bet'=>100]);
  49. DB::table('share_policies')->insert(['effective_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1,
  50. 'valid_member_min_deposit'=>20,'valid_member_min_effective_bet'=>100,'rules'=>json_encode(ShareDefaults::rules())]);
  51. $this->relations=new ShareRelations(); $this->relations->initialize(true); $this->service=new ShareSettlementService();
  52. }
  53. protected function tearDown(): void
  54. {
  55. Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
  56. }
  57. private function fact(int $user,string $amount,string $date='2026-10-24',array $extra=[]): int
  58. {
  59. return DB::table('share_bet_facts')->insertGetId($extra+['source'=>'fixture','source_id'=>uniqid('',true),'user_id'=>$user,'member_id'=>'M'.$user,
  60. 'category'=>'third:2','bet_at'=>$date.' 12:00:00','bet_date'=>$date,'settled_at'=>'2026-10-25 10:00:00',
  61. 'bet_amount'=>$amount,'net_profit'=>'-'.$amount,'effective_amount'=>$amount,'settled'=>1,'voided'=>0,'currency'=>'CNY','source_version'=>str_repeat('a',64)]);
  62. }
  63. 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); }
  64. public function test_nine_categories_have_the_exact_twelve_tiers_and_both_qualifiers_are_required(): void
  65. {
  66. $this->assertCount(9,ShareDefaults::rules());
  67. $rules=ShareDefaults::rules()['third:2']; $this->assertCount(12,$rules);
  68. $this->assertSame('0.25',ShareSettlementService::rate($rules,1,'1000000'));
  69. $this->assertSame('0',ShareSettlementService::rate($rules,0,'99.99'));
  70. $this->assertSame('0.10',ShareSettlementService::rate($rules,0,'100'));
  71. $this->assertSame('0.70',ShareSettlementService::rate($rules,5,'1700000'));
  72. }
  73. public function test_receiver_own_rate_is_compounded_by_depth_and_retries_are_idempotent(): void
  74. {
  75. $this->fact(2,'200'); $this->fact(3,'20000');
  76. $projection=$this->service->projection('2026-10-24');
  77. $a=$projection['rows'][1]['third:2']; $b=$projection['rows'][2]['third:2'];
  78. $this->assertEquals(0.1,$a['rate']); $this->assertEquals(0.2,$b['rate']);
  79. $this->assertSame('0.0200000000',$a['indirect_commission']);
  80. $this->assertSame('40.0000000000',$b['direct_commission']);
  81. $before=DB::table('wallets')->get()->toJson();
  82. $first=$this->service->settle('2026-10-24');
  83. $this->assertSame(2,$first['receivers']); $this->assertSame('0.2200000000',$this->amount(1));
  84. $this->assertSame('40.0000000000',$this->amount(2));
  85. $entries=DB::table('share_ledger')->count();
  86. $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
  87. $this->assertSame($entries,DB::table('share_ledger')->count());
  88. $this->assertSame($before,DB::table('wallets')->get()->toJson());
  89. }
  90. public function test_third_depth_adds_another_receiver_rate_factor(): void
  91. {
  92. $this->fact(2,'200'); $this->fact(3,'20000'); $this->fact(4,'1000');
  93. $row=$this->service->projection('2026-10-24')['rows'][1]['third:2'];
  94. $this->assertSame('21000.0000000000',$row['indirect_performance']);
  95. $this->assertSame('0.0200010000',$row['indirect_commission']);
  96. $this->assertSame(3,DB::table('share_tree')->where('ancestor_user_id',1)->where('descendant_user_id',4)->value('depth'));
  97. }
  98. public function test_ten_percent_compounds_through_four_levels_using_only_the_receiver_rate(): void
  99. {
  100. DB::table('users')->insert(['id'=>8,'member_id'=>'M8','user_code'=>'F','agent_user_code'=>'D','created_at'=>'2026-10-01 00:00:00']);
  101. DB::table('balance_logs')->insert(['member_id'=>'M8','change_type'=>'充值','amount'=>20,'created_at'=>'2026-10-20 00:00:00']);
  102. $this->relations->initialize(true);
  103. $rules=ShareDefaults::rules();
  104. $rules['third:2']=[
  105. ['min_valid_members'=>0,'min_performance'=>'0','rate'=>'10'],
  106. ['min_valid_members'=>0,'min_performance'=>'2000','rate'=>'20'],
  107. ];
  108. DB::table('share_policies')->where('effective_date','2026-10-24')->update(['rules'=>json_encode($rules)]);
  109. $this->fact(2,'1000'); $this->fact(3,'2000'); $this->fact(4,'1000'); $this->fact(8,'1000');
  110. $rows=$this->service->projection('2026-10-24')['rows'];
  111. $a=$rows[1]['third:2'];
  112. $this->assertSame('10',$a['rate']);
  113. $this->assertSame('20',$rows[2]['third:2']['rate']);
  114. $this->assertSame('100.0000000000',$a['direct_commission']);
  115. $this->assertSame('4000.0000000000',$a['indirect_performance']);
  116. // 2000*10%*10% + 1000*10%^3 + 1000*10%^4 = 20+1+0.1.
  117. $this->assertSame('21.1000000000',$a['indirect_commission']);
  118. $this->service->settle('2026-10-24');
  119. $this->assertSame('121.1000000000',$this->amount(1));
  120. $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
  121. }
  122. public function test_depth_buckets_keep_precision_until_the_final_aggregate(): void
  123. {
  124. $second=ShareSettlementService::depthCommission('0.000000006','10',2);
  125. $third=ShareSettlementService::depthCommission('0.00000006','10',3);
  126. $this->assertSame('0.000000000060000000000000000000',$second);
  127. $this->assertSame('0.000000000060000000000000000000',$third);
  128. $this->assertSame('0.0000000001',bcadd(bcadd($second,$third,30),'0',10));
  129. $this->assertSame('0.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','0',99));
  130. $this->assertSame('1000.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','100',999));
  131. $this->assertSame('0.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','0.1',999));
  132. }
  133. public function test_formula_cutover_preserves_booked_history_and_changes_only_following_days(): void
  134. {
  135. $this->fact(2,'200'); $this->fact(3,'20000');
  136. DB::table('share_policies')->where('effective_date','2026-10-24')->update(['indirect_bonus_rate'=>30]);
  137. DB::table('share_system_state')->insert(['key'=>'commission_depth_rate_start','value'=>'2099-01-01']);
  138. $this->service->settle('2026-10-24');
  139. $this->assertSame('6.2000000000',$this->amount(1));
  140. DB::table('share_system_state')->where('key','commission_depth_rate_start')->delete();
  141. $before=DB::table('share_accounts')->get()->toJson();
  142. $ledger=DB::table('share_ledger')->get()->toJson();
  143. $wallets=DB::table('wallets')->get()->toJson();
  144. $migration=require dirname(__DIR__,2).'/database/migrations/2026_10_24_150000_use_depth_rates_for_member_sharing.php';
  145. $migration->up();
  146. $this->assertSame('2026-10-26',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value'));
  147. $this->assertSame($before,DB::table('share_accounts')->get()->toJson());
  148. $this->assertSame($ledger,DB::table('share_ledger')->get()->toJson());
  149. $this->assertSame($wallets,DB::table('wallets')->get()->toJson());
  150. $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
  151. $this->fact(2,'200','2026-10-26'); $this->fact(3,'20000','2026-10-26');
  152. $this->assertSame('0.0200000000',$this->service->projection('2026-10-26')['rows'][1]['third:2']['indirect_commission']);
  153. Carbon::setTestNow(Carbon::parse('2026-10-27 12:00:00','Asia/Shanghai'));
  154. $migration->up();
  155. $this->assertSame('2026-10-26',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value'));
  156. }
  157. public function test_fresh_installation_requires_no_legacy_bonus_or_waiting_day(): void
  158. {
  159. DB::table('share_settings')->update(['activation_date'=>null]);
  160. $migration=require dirname(__DIR__,2).'/database/migrations/2026_10_24_150000_use_depth_rates_for_member_sharing.php';
  161. $migration->up();
  162. $this->assertSame('1970-01-01',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value'));
  163. $migration->down();
  164. $this->assertFalse(DB::table('share_system_state')->where('key','commission_depth_rate_start')->exists());
  165. }
  166. public function test_qualification_excludes_performance_and_later_deposits_do_not_requalify_previous_days(): void
  167. {
  168. $this->fact(2,'200'); $this->fact(3,'20000');
  169. DB::table('balance_logs')->where('member_id','M3')->update(['created_at'=>'2026-10-25 00:01:00']);
  170. $projection=$this->service->projection('2026-10-24');
  171. $this->assertArrayNotHasKey(2,$projection['rows']);
  172. $this->assertSame('0',$projection['rows'][1]['third:2']['indirect_performance']);
  173. $this->assertSame(1,$projection['rows'][1]['third:2']['valid_direct_members']);
  174. $this->fact(2,'10','2026-10-25'); $this->fact(3,'20000','2026-10-25');
  175. $this->assertArrayHasKey(2,$this->service->projection('2026-10-25')['rows']);
  176. }
  177. public function test_all_game_cumulative_qualification_but_category_tiers_stay_independent(): void
  178. {
  179. $this->fact(2,'50'); $this->fact(2,'60','2026-10-23',['category'=>'self:sport']);
  180. $this->fact(5,'20000','2026-10-24',['category'=>'self:lottery']);
  181. $rows=$this->service->projection('2026-10-24')['rows'][1];
  182. $this->assertSame('50.0000000000',$rows['third:2']['direct_performance']);
  183. $this->assertSame('0',$rows['third:2']['rate']);
  184. $this->assertSame('0.20',$rows['self:lottery']['rate']);
  185. $this->assertSame(2,$rows['self:lottery']['valid_direct_members']);
  186. }
  187. public function test_late_result_reconciles_original_day_and_later_cumulative_qualifications(): void
  188. {
  189. $this->fact(2,'200'); $this->fact(3,'99');
  190. $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1));
  191. $this->fact(3,'1','2026-10-23');
  192. DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-23"]']);
  193. $result=$this->service->reconcile();
  194. $this->assertSame(2,$result['dates']); $this->assertSame('0.2000990000',$this->amount(1));
  195. $this->assertSame('[]',DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'));
  196. }
  197. public function test_paid_reversal_offsets_future_earnings_without_touching_main_wallet_or_reserved_claim(): void
  198. {
  199. $this->fact(2,'200'); $c=$this->fact(3,'20000'); $this->service->settle('2026-10-24');
  200. DB::table('share_accounts')->where('user_id',1)->update(['paid_amount'=>'0.22']);
  201. $wallet=DB::table('wallets')->get()->toJson();
  202. DB::table('share_bet_facts')->where('id',$c)->update(['voided'=>1,'effective_amount'=>0,'source_version'=>'canceled']);
  203. $this->service->settle('2026-10-24');
  204. $this->assertSame('0.2000000000',$this->amount(1)); $this->assertSame('0.2200000000',$this->amount(1,'paid_amount'));
  205. $this->assertSame('0.0000000000',$this->amount(2));
  206. Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00','Asia/Shanghai'));
  207. $this->fact(2,'10000','2026-10-25'); $this->service->settle('2026-10-25');
  208. $this->assertSame('10.2000000000',$this->amount(1));
  209. $this->assertSame('9.9800000000',bcsub($this->amount(1),$this->amount(1,'paid_amount'),10));
  210. $this->assertSame($wallet,DB::table('wallets')->get()->toJson());
  211. }
  212. private function queueDates(array $dates): void
  213. {
  214. DB::table('share_system_state')->updateOrInsert(['key'=>'facts_dirty_dates'],['value'=>json_encode($dates)]);
  215. }
  216. private function queuedDates(): array
  217. {
  218. return json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value') ?? '[]',true);
  219. }
  220. private function correctionAffectingLaterDays(): void
  221. {
  222. Carbon::setTestNow(Carbon::parse('2026-10-27 12:00:00','Asia/Shanghai'));
  223. $original=$this->fact(2,'200');
  224. $this->fact(2,'50','2026-10-25'); $this->fact(5,'200','2026-10-25');
  225. $this->fact(2,'40','2026-10-26'); $this->fact(5,'200','2026-10-26');
  226. foreach (['2026-10-24','2026-10-25','2026-10-26'] as $date) $this->service->settle($date);
  227. $this->assertSame('0.6900000000',$this->amount(1));
  228. DB::table('share_bet_facts')->where('id',$original)->update(['voided'=>1,'effective_amount'=>'0','source_version'=>'voided']);
  229. $this->queueDates(['2026-10-24']);
  230. }
  231. public function test_reconciliation_persists_all_dependent_days_before_starting_work(): void
  232. {
  233. $this->correctionAffectingLaterDays();
  234. $service=new class extends ShareSettlementService {
  235. public function settle(string $date,bool $write=true,bool $allowToday=false): array
  236. {
  237. $pending=json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'),true);
  238. \PHPUnit\Framework\Assert::assertSame(['2026-10-24','2026-10-25','2026-10-26'],$pending);
  239. throw new \RuntimeException('stop before settlement');
  240. }
  241. };
  242. try { $service->reconcile(); $this->fail('interruption expected'); }
  243. catch (\RuntimeException $e) { $this->assertSame('stop before settlement',$e->getMessage()); }
  244. $this->assertSame(['2026-10-24','2026-10-25','2026-10-26'],$this->queuedDates());
  245. $this->assertSame('0.6900000000',$this->amount(1)); $this->assertSame(0,DB::transactionLevel());
  246. }
  247. public function test_partial_reconciliation_preserves_dependent_days_and_resume_does_not_double_credit(): void
  248. {
  249. $this->correctionAffectingLaterDays();
  250. $service=new class extends ShareSettlementService {
  251. public function settle(string $date,bool $write=true,bool $allowToday=false): array
  252. {
  253. if ($date==='2026-10-25') throw new \RuntimeException('stop before second date');
  254. return parent::settle($date,$write,$allowToday);
  255. }
  256. };
  257. try { $service->reconcile(); $this->fail('interruption expected'); }
  258. catch (\RuntimeException $e) { $this->assertSame('stop before second date',$e->getMessage()); }
  259. $this->assertSame(['2026-10-25','2026-10-26'],$this->queuedDates());
  260. $this->assertSame('0.4900000000',$this->amount(1));
  261. $result=$this->service->reconcile(); $this->assertSame(2,$result['dates']);
  262. $this->assertSame('0.4000000000',$this->amount(1)); $this->assertSame([],$this->queuedDates());
  263. $entries=DB::table('share_ledger')->count();
  264. $this->assertSame(['dates'=>0,'changed'=>0],$this->service->reconcile());
  265. $this->assertSame($entries,DB::table('share_ledger')->count());
  266. }
  267. public function test_daily_reconciliation_rolls_back_ledger_if_interrupted_before_acknowledgement(): void
  268. {
  269. $this->correctionAffectingLaterDays(); $before=DB::table('share_ledger')->get()->toJson();
  270. $service=new class extends ShareSettlementService {
  271. public function settle(string $date,bool $write=true,bool $allowToday=false): array
  272. {
  273. parent::settle($date,$write,$allowToday);
  274. throw new \RuntimeException('stop after ledger writes');
  275. }
  276. };
  277. try { $service->reconcile(); $this->fail('interruption expected'); }
  278. catch (\RuntimeException $e) { $this->assertSame('stop after ledger writes',$e->getMessage()); }
  279. $this->assertSame($before,DB::table('share_ledger')->get()->toJson());
  280. $this->assertSame('0.6900000000',$this->amount(1));
  281. $this->assertSame(['2026-10-24','2026-10-25','2026-10-26'],$this->queuedDates());
  282. $this->service->reconcile(); $this->assertSame('0.4000000000',$this->amount(1));
  283. }
  284. public function test_empty_historical_day_is_acknowledged_but_open_days_survive_until_midnight(): void
  285. {
  286. $this->queueDates(['2026-10-23','2026-10-25','2026-10-26']);
  287. $this->assertSame(['dates'=>1,'changed'=>0],$this->service->reconcile());
  288. $this->assertSame(['2026-10-25','2026-10-26'],$this->queuedDates());
  289. Carbon::setTestNow(Carbon::parse('2026-10-26 00:01:00','Asia/Shanghai'));
  290. $this->assertSame(['dates'=>1,'changed'=>0],$this->service->reconcile());
  291. $this->assertSame(['2026-10-26'],$this->queuedDates()); $this->assertSame(0,DB::table('share_ledger')->count());
  292. }
  293. public static function recoveryKillPoints(): array
  294. {
  295. return [
  296. 'after first date committed'=>['2026-10-25','before',['2026-10-25','2026-10-26'],'0.4900000000'],
  297. 'after ledger write before acknowledgement'=>['2026-10-24','after',['2026-10-24','2026-10-25','2026-10-26'],'0.6900000000'],
  298. ];
  299. }
  300. /** @dataProvider recoveryKillPoints */
  301. public function test_sigkill_and_fresh_service_resume_persisted_dates(string $date,string $point,array $pending,string $earned): void
  302. {
  303. if (!function_exists('posix_kill') || !defined('SIGKILL') || !function_exists('proc_open')) $this->markTestSkipped('Requires POSIX signals and subprocesses');
  304. $this->correctionAffectingLaterDays();
  305. $directory=sys_get_temp_dir().'/bot28-share-reconcile-'.bin2hex(random_bytes(6));
  306. mkdir($directory,0700); $database=$directory.'/db.sqlite';
  307. $default=DB::getDefaultConnection(); $process=null; $pipes=[];
  308. try {
  309. // Clone the isolated fixture, never an application or existing database.
  310. DB::statement('VACUUM INTO ?',[$database]);
  311. $process=proc_open([PHP_BINARY,'-d','error_reporting=22527',dirname(__DIR__).'/fixtures/sharing_reconcile_recovery_worker.php',$database,$date,$point],
  312. [0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
  313. $this->assertIsResource($process); fclose($pipes[0]);
  314. $deadline=microtime(true)+5;
  315. do { $status=proc_get_status($process); if (!$status['running']) break; usleep(1000); } while (microtime(true)<$deadline);
  316. $this->assertFalse($status['running'],'Recovery worker did not terminate within five seconds');
  317. $stdout=stream_get_contents($pipes[1]); $stderr=stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
  318. proc_close($process); $process=null;
  319. $this->assertTrue($status['signaled'],$stdout.$stderr.json_encode($status)); $this->assertSame(SIGKILL,$status['termsig']);
  320. $marker=json_decode(file_get_contents($directory.'/interrupted.json'),true,512,JSON_THROW_ON_ERROR);
  321. $this->assertSame($date,$marker['date']); $this->assertSame($point,$marker['point']);
  322. app('config')->set('database.connections.reconcile_recovery',['driver'=>'sqlite','database'=>$database,'prefix'=>'bot_']);
  323. DB::setDefaultConnection('reconcile_recovery');
  324. $this->assertSame($pending,$this->queuedDates()); $this->assertSame($earned,$this->amount(1));
  325. $restarted=new ShareSettlementService(); $result=$restarted->reconcile();
  326. $this->assertSame(count($pending),$result['dates']); $this->assertSame([],$this->queuedDates());
  327. $this->assertSame('0.4000000000',$this->amount(1));
  328. $entries=DB::table('share_ledger')->count(); $this->assertSame(['dates'=>0,'changed'=>0],$restarted->reconcile());
  329. $this->assertSame($entries,DB::table('share_ledger')->count());
  330. } finally {
  331. if (is_resource($process)) { proc_terminate($process,9); proc_close($process); }
  332. foreach ($pipes as $pipe) if (is_resource($pipe)) fclose($pipe);
  333. DB::setDefaultConnection($default); DB::purge('reconcile_recovery');
  334. foreach (glob($directory.'/*') ?: [] as $file) unlink($file);
  335. rmdir($directory);
  336. }
  337. }
  338. public function test_policy_snapshot_is_used_for_replay_and_preview_is_read_only(): void
  339. {
  340. $fact=$this->fact(2,'200'); $this->service->settle('2026-10-24');
  341. $rules=ShareDefaults::rules(); $rules['third:2']=[['min_valid_members'=>0,'min_performance'=>'0','rate'=>'99']];
  342. DB::table('share_policies')->insert(['effective_date'=>'2026-10-25','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_deposit'=>20,
  343. 'valid_member_min_effective_bet'=>100,'rules'=>json_encode($rules)]);
  344. DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>'300']);
  345. $before=DB::table('share_accounts')->get()->toJson();
  346. $this->assertSame('0.1000000000',$this->service->settle('2026-10-24',false)['delta']);
  347. $this->assertSame($before,DB::table('share_accounts')->get()->toJson());
  348. $this->service->settle('2026-10-24'); $this->assertSame('0.3000000000',$this->amount(1));
  349. }
  350. public function test_grades_use_incremental_thresholds_and_never_downgrade_on_correction(): void
  351. {
  352. DB::table('share_levels')->insert([['level'=>2,'name'=>'二级','incremental_commission'=>100],['level'=>3,'name'=>'三级','incremental_commission'=>300]]);
  353. $fact=$this->fact(2,'50000'); $this->service->settle('2026-10-24');
  354. $this->assertSame(2,DB::table('share_members')->where('user_id',1)->value('level'));
  355. DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>160000]);
  356. $this->service->settle('2026-10-24');
  357. $this->assertSame('400.0000000000',$this->amount(1));
  358. $this->assertSame(3,DB::table('share_members')->where('user_id',1)->value('level'));
  359. DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>200]); $this->service->settle('2026-10-24');
  360. $this->assertSame(3,DB::table('share_members')->where('user_id',1)->value('level'));
  361. }
  362. public function test_later_first_binding_keeps_subtree_but_does_not_give_old_bets_to_new_ancestor(): void
  363. {
  364. $this->fact(2,'200'); $this->fact(3,'20000');
  365. $this->relations->bind(1,6,'A');
  366. $this->assertSame(3,DB::table('share_tree')->where('ancestor_user_id',6)->where('descendant_user_id',3)->value('depth'));
  367. $this->assertArrayNotHasKey(6,$this->service->projection('2026-10-24')['rows']);
  368. $this->expectException(\RuntimeException::class); $this->relations->bind(6,3,'X');
  369. }
  370. public function test_invalid_legacy_codes_and_cycles_are_isolated_without_guessing_or_mutating_users(): void
  371. {
  372. foreach ([[8,'P','Q'],[9,'Q','P'],[10,'R','MISSING'],[11,'DUP',null],[12,'DUP',null],[13,'CHILD','DUP']] as [$id,$code,$parent]) {
  373. 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']);
  374. }
  375. $users=DB::table('users')->get()->toJson();
  376. $preview=$this->relations->initialize(); $this->assertSame(6,$preview['created']);
  377. $this->assertSame(7,DB::table('share_members')->count());
  378. $this->relations->initialize(true);
  379. $this->assertSame('cycle',DB::table('share_members')->where('user_id',8)->value('binding_status'));
  380. $this->assertNull(DB::table('share_members')->where('user_id',9)->value('parent_user_id'));
  381. $this->assertSame('missing',DB::table('share_members')->where('user_id',10)->value('binding_status'));
  382. $this->assertSame('ambiguous',DB::table('share_members')->where('user_id',13)->value('binding_status'));
  383. $this->assertSame($users,DB::table('users')->get()->toJson());
  384. $this->assertSame(0,$this->relations->initialize(true)['created']);
  385. }
  386. public function test_fault_rolls_back_every_ledger_account_and_period_write(): void
  387. {
  388. $this->fact(2,'200');
  389. DB::statement("CREATE TRIGGER reject_share_ledger BEFORE INSERT ON bot_share_ledger BEGIN SELECT RAISE(ABORT,'injected failure'); END");
  390. try { $this->service->settle('2026-10-24'); $this->fail('ledger failure expected'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('injected failure',$e->getMessage()); }
  391. $this->assertSame('0.0000000000',$this->amount(1)); $this->assertSame(0,DB::table('share_settlements')->count());
  392. $this->assertSame(0,DB::table('share_ledger')->count()); $this->assertSame(0,DB::transactionLevel());
  393. DB::statement('DROP TRIGGER reject_share_ledger');
  394. $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1));
  395. }
  396. public function test_today_is_rejected_without_debug_opt_in_and_foreign_currency_is_not_combined(): void
  397. {
  398. $this->fact(2,'200'); $this->fact(3,'20000','2026-10-24',['currency'=>'USD']);
  399. $this->assertSame('0',$this->service->projection('2026-10-24')['rows'][1]['third:2']['indirect_performance']);
  400. $this->expectException(\InvalidArgumentException::class); $this->service->settle('2026-10-25');
  401. }
  402. private function command(array $options): CommandTester
  403. {
  404. $command = new SettleSharing();
  405. $command->setLaravel(app());
  406. $tester = new CommandTester($command);
  407. $tester->execute($options);
  408. return $tester;
  409. }
  410. public function test_debug_today_credits_once_revises_only_the_delta_and_closes_normally_after_midnight(): void
  411. {
  412. $this->service->settle('2026-10-24');
  413. $fact = $this->fact(2, '200', '2026-10-25');
  414. $wallets = DB::table('wallets')->get()->toJson();
  415. $first = $this->service->settle('2026-10-25', true, true);
  416. $this->assertTrue($first['written']);
  417. $this->assertSame('0.2000000000', $this->amount(1));
  418. $this->assertSame('2026-10-24', DB::table('share_system_state')->where('key', 'last_settlement_date')->value('value'));
  419. $this->assertContains('2026-10-25', json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
  420. $entries = DB::table('share_ledger')->count();
  421. $this->assertSame(0, $this->service->settle('2026-10-25', true, true)['changed']);
  422. $this->assertSame($entries, DB::table('share_ledger')->count());
  423. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => 400]);
  424. $this->assertSame('0.2000000000', $this->service->settle('2026-10-25', true, true)['delta']);
  425. $this->assertSame('0.4000000000', $this->amount(1));
  426. $this->assertSame(0, $this->service->reconcile()['dates']);
  427. Carbon::setTestNow(Carbon::parse('2026-10-26 00:01:00', 'Asia/Shanghai'));
  428. $entries = DB::table('share_ledger')->count();
  429. $this->assertSame(1, $this->service->reconcile()['dates']);
  430. $this->assertSame('2026-10-25', DB::table('share_system_state')->where('key', 'last_settlement_date')->value('value'));
  431. $this->assertSame($entries, DB::table('share_ledger')->count());
  432. $this->assertSame('0.4000000000', $this->amount(1));
  433. $this->assertSame($wallets, DB::table('wallets')->get()->toJson());
  434. }
  435. public function test_allow_today_flag_defaults_to_today_and_ordinary_command_still_defaults_to_yesterday(): void
  436. {
  437. $this->fact(2, '200'); $this->fact(2, '300', '2026-10-25');
  438. $normal = $this->command([]);
  439. $this->assertSame(0, $normal->getStatusCode());
  440. $this->assertSame('2026-10-24', json_decode($normal->getDisplay(), true)['date']);
  441. $this->assertSame('0.2000000000', $this->amount(1));
  442. $blocked = $this->command(['date' => '2026-10-25']);
  443. $this->assertSame(1, $blocked->getStatusCode());
  444. $this->assertStringContainsString('--allow-today', $blocked->getDisplay());
  445. $debug = $this->command(['--allow-today' => true]);
  446. $this->assertSame(0, $debug->getStatusCode());
  447. $this->assertSame('2026-10-25', json_decode($debug->getDisplay(), true)['date']);
  448. $this->assertSame('0.5000000000', $this->amount(1));
  449. $again = $this->command(['date' => '2026-10-25', '--allow-today' => true]);
  450. $this->assertSame(0, $again->getStatusCode());
  451. $this->assertSame(0, json_decode($again->getDisplay(), true)['changed']);
  452. $this->assertSame('2026-10-24', DB::table('share_system_state')->where('key', 'last_settlement_date')->value('value'));
  453. }
  454. public function test_debug_preview_today_is_read_only_including_levels_markers_and_reconciliation_queue(): void
  455. {
  456. $this->fact(2, '50000', '2026-10-25');
  457. DB::table('share_levels')->insert(['level' => 2, 'name' => '二级', 'incremental_commission' => 100]);
  458. $tables = ['share_accounts', 'share_settlements', 'share_ledger', 'share_members', 'share_system_state', 'wallets'];
  459. $before = [];
  460. foreach ($tables as $table) $before[$table] = DB::table($table)->get()->toJson();
  461. $preview = $this->command(['--allow-today' => true, '--preview' => true]);
  462. $this->assertSame(0, $preview->getStatusCode());
  463. $data = json_decode($preview->getDisplay(), true);
  464. $this->assertSame('2026-10-25', $data['date']);
  465. $this->assertFalse($data['written']);
  466. $this->assertGreaterThan(0, $data['changed']);
  467. foreach ($tables as $table) $this->assertSame($before[$table], DB::table($table)->get()->toJson(), $table);
  468. }
  469. /** @dataProvider forbiddenDebugOptions */
  470. public function test_future_dates_and_incompatible_debug_options_are_rejected_before_writing(array $options): void
  471. {
  472. $this->fact(2, '200', '2026-10-25');
  473. $before = DB::table('share_system_state')->get()->toJson();
  474. $command = $this->command($options);
  475. $this->assertSame(1, $command->getStatusCode());
  476. $this->assertSame(0, DB::table('share_ledger')->count());
  477. $this->assertSame(0, DB::table('share_settlements')->count());
  478. $this->assertSame('0.0000000000', $this->amount(1));
  479. $this->assertSame($before, DB::table('share_system_state')->get()->toJson());
  480. }
  481. public static function forbiddenDebugOptions(): array
  482. {
  483. return [
  484. 'future' => [['date' => '2026-10-26']],
  485. 'future with opt in' => [['date' => '2026-10-26', '--allow-today' => true]],
  486. 'future preview' => [['date' => '2026-10-26', '--allow-today' => true, '--preview' => true]],
  487. 'invalid calendar date' => [['date' => '2026-02-30', '--allow-today' => true]],
  488. 'reconcile' => [['--allow-today' => true, '--reconcile' => true]],
  489. 'reconcile and preview' => [['--allow-today' => true, '--reconcile' => true, '--preview' => true]],
  490. 'diagnose and reconcile' => [['--diagnose' => true, '--reconcile' => true]],
  491. ];
  492. }
  493. public function test_debug_today_uses_beijing_date_even_when_utc_is_still_yesterday(): void
  494. {
  495. Carbon::setTestNow(Carbon::parse('2026-10-25 00:01:00', 'Asia/Shanghai'));
  496. $command = $this->command(['--allow-today' => true]);
  497. $this->assertSame(0, $command->getStatusCode());
  498. $this->assertSame('2026-10-25', json_decode($command->getDisplay(), true)['date']);
  499. $this->assertFalse(DB::table('share_system_state')->where('key', 'last_settlement_date')->exists());
  500. }
  501. public function test_diagnose_reports_unavailable_policy_without_using_retired_activation_or_changing_state(): void
  502. {
  503. $this->fact(2, '200', '2026-10-25');
  504. DB::table('share_settings')->update(['activation_date' => '2026-10-26']);
  505. DB::table('share_policies')->where('effective_date','<>','2026-10-24')->delete();
  506. DB::table('share_policies')->update(['effective_date' => '2026-10-26']);
  507. $tables=['share_accounts','share_ledger','share_settlements','share_system_state','share_members','wallets'];
  508. $before=[];
  509. foreach ($tables as $table) $before[$table]=DB::table($table)->get()->toJson();
  510. $command=$this->command(['date'=>'2026-10-25','--allow-today'=>true,'--diagnose'=>true]);
  511. $this->assertSame(0,$command->getStatusCode());
  512. $data=json_decode($command->getDisplay(),true);
  513. $this->assertFalse($data['written']);
  514. $this->assertArrayNotHasKey('activation_date',$data);
  515. $this->assertSame('2026-10-26',$data['next_policy_effective_date']);
  516. $this->assertNull($data['policy']);
  517. $this->assertSame(['no_effective_policy'],array_column($data['blockers'],'code'));
  518. $this->assertSame(1,$data['facts']['candidates']);
  519. $this->assertSame(0,$data['receivers']);
  520. foreach ($tables as $table) $this->assertSame($before[$table],DB::table($table)->get()->toJson(),$table);
  521. }
  522. public function test_diagnose_counts_pending_voided_foreign_and_zero_effective_facts_and_uses_actual_projection_totals(): void
  523. {
  524. $this->fact(2,'200','2026-10-25');
  525. $this->fact(2,'200','2026-10-25',['settled'=>0]);
  526. $this->fact(2,'200','2026-10-25',['voided'=>1]);
  527. $this->fact(2,'200','2026-10-25',['currency'=>'USD']);
  528. $this->fact(2,'200','2026-10-25',['effective_amount'=>0]);
  529. $data=$this->service->diagnose('2026-10-25');
  530. $this->assertSame(['total'=>5,'unsettled'=>1,'voided'=>1,'currency_mismatch'=>1,'zero_effective'=>1,'candidates'=>1],$data['facts']);
  531. $this->assertSame($data['facts'],$data['facts_by_source']['fixture']);
  532. $this->assertSame([],$data['blockers']);
  533. $this->assertSame(1,$data['receivers']);
  534. $this->assertSame(1,$data['positive_receivers']);
  535. $this->assertSame('0.2000000000',$data['projected_commission']);
  536. $this->assertSame(0,DB::table('share_ledger')->count());
  537. }
  538. /** @dataProvider retiredGates */
  539. public function test_complete_policy_settles_immediately_without_legacy_switch_or_activation_date($activation,int $benefit): void
  540. {
  541. DB::table('share_settings')->update(['activation_date'=>$activation,'benefit_enabled'=>$benefit]);
  542. DB::table('share_policies')->update(['benefit_enabled'=>$benefit]);
  543. $this->fact(2,'200','2026-10-25');
  544. $diagnosis=$this->service->diagnose('2026-10-25');
  545. $this->assertSame([],$diagnosis['blockers']);
  546. $this->assertArrayNotHasKey('activation_date',$diagnosis);
  547. $this->assertArrayNotHasKey('benefit_enabled',$diagnosis['policy']);
  548. $this->assertSame(1,$diagnosis['receivers']);
  549. $this->assertSame(1,$this->service->settle('2026-10-25',true,true)['receivers']);
  550. $this->assertSame('0.2000000000',$this->amount(1));
  551. $this->assertSame(0,$this->service->settle('2026-10-25',true,true)['changed']);
  552. }
  553. public static function retiredGates(): array
  554. {
  555. return [[null,0],['2099-01-01',0],[null,1],['2099-01-01',1]];
  556. }
  557. public function test_reconcile_has_no_initial_activation_cutoff_and_incomplete_threshold_is_still_reported(): void
  558. {
  559. DB::table('share_settings')->update(['activation_date'=>null,'benefit_enabled'=>0]);
  560. DB::table('share_policies')->update(['benefit_enabled'=>0]);
  561. $this->fact(2,'200');
  562. DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-24"]']);
  563. $this->assertSame(1,$this->service->reconcile()['changed']);
  564. $this->assertSame('0.2000000000',$this->amount(1));
  565. DB::table('share_policies')->update(['valid_member_min_effective_bet'=>null]);
  566. $diagnosis=$this->service->diagnose('2026-10-25');
  567. $this->assertSame(['missing_effective_bet_threshold'],array_column($diagnosis['blockers'],'code'));
  568. }
  569. /** @dataProvider diagnosticExclusions */
  570. public function test_diagnose_explains_the_same_member_and_relationship_filters_as_settlement(string $reason): void
  571. {
  572. $this->fact(2,'200','2026-10-25');
  573. switch ($reason) {
  574. case 'deposit_below_threshold': DB::table('balance_logs')->where('member_id','M2')->delete(); break;
  575. case 'effective_bet_below_threshold': DB::table('share_bet_facts')->update(['effective_amount'=>50]); break;
  576. case 'no_ancestor_relationship': DB::table('share_tree')->where('descendant_user_id',2)->where('depth','>',0)->delete(); break;
  577. 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;
  578. case 'bettor_unavailable': DB::table('users')->where('id',2)->update(['from'=>2]); break;
  579. case 'unsupported_category': DB::table('share_bet_facts')->update(['category'=>'unknown']); break;
  580. case 'receiver_unavailable': DB::table('users')->where('id',1)->update(['from'=>2]); break;
  581. }
  582. $data=$this->service->diagnose('2026-10-25');
  583. $this->assertSame(1,$data['facts']['candidates']);
  584. $this->assertSame(1,$data['excluded'][$reason]);
  585. $this->assertSame(0,$data['receivers']);
  586. $this->assertSame(0,count($this->service->projection('2026-10-25')['rows']));
  587. $this->assertSame('0.0000000000',$data['projected_commission']);
  588. }
  589. public static function diagnosticExclusions(): array
  590. {
  591. return array_map(fn($reason)=>[$reason],['deposit_below_threshold','effective_bet_below_threshold',
  592. 'no_ancestor_relationship','relationship_bound_after_bet','bettor_unavailable','unsupported_category','receiver_unavailable']);
  593. }
  594. public function test_open_day_stays_queued_and_a_missed_midnight_run_is_automatically_recovered(): void
  595. {
  596. Carbon::setTestNow(Carbon::parse('2026-10-24 12:00:00','Asia/Shanghai'));
  597. $this->fact(2,'200');
  598. DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-24"]']);
  599. $this->assertSame(0,$this->service->reconcile()['dates']);
  600. $this->assertSame(['2026-10-24'],json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'),true));
  601. Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00','Asia/Shanghai'));
  602. $this->service->settle('2026-10-25');
  603. $this->assertSame(1,$this->service->reconcile()['dates']);
  604. $this->assertSame('0.2000000000',$this->amount(1));
  605. }
  606. public function test_real_members_without_an_invitation_code_receive_one_once_without_rebinding(): void
  607. {
  608. 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']);
  609. $this->assertSame(1,$this->relations->initialize()['invitation_codes_assigned']);
  610. $this->assertSame('',DB::table('users')->where('id',8)->value('user_code'));
  611. $this->relations->initialize(true);
  612. $code=DB::table('users')->where('id',8)->value('user_code');
  613. $this->assertNotSame('',$code); $this->assertNotSame('0',$code);
  614. $this->assertSame($code,DB::table('share_members')->where('user_id',8)->value('invitation_code'));
  615. $this->assertNull(DB::table('share_members')->where('user_id',8)->value('parent_user_id'));
  616. $this->relations->initialize(true);
  617. $this->assertSame($code,DB::table('users')->where('id',8)->value('user_code'));
  618. }
  619. public function test_entry_visibility_does_not_control_monetary_policy_and_pre_activation_bets_never_pay(): void
  620. {
  621. $this->fact(2,'200');
  622. DB::table('share_policies')->where('effective_date','2026-10-24')->update(['enabled'=>0]);
  623. $this->service->settle('2026-10-24');
  624. $this->assertSame('0.2000000000',$this->amount(1));
  625. $this->assertSame([],$this->service->projection('2026-10-23')['rows']);
  626. $this->assertSame(0,$this->service->settle('2026-10-23')['changed']);
  627. }
  628. public function test_new_telegram_member_after_initialization_gets_a_root_and_account_without_reinitializing_others(): void
  629. {
  630. if (!function_exists('removeZero')) require_once dirname(__DIR__,2).'/app/Helpers/helpers.php';
  631. DB::table('wallets')->insert(['member_id'=>'M99']);
  632. $user=\App\Services\PublicService::index('M99','telegram-new','新会员');
  633. $id=(int)$user->id;
  634. $this->assertSame(-1,(int)$user->from);
  635. $this->assertSame(1,(int)$user->recharge_channel_group_id);
  636. $this->assertSame('root',DB::table('share_members')->where('user_id',$id)->value('binding_status'));
  637. $this->assertSame((string)$user->user_code,DB::table('share_members')->where('user_id',$id)->value('invitation_code'));
  638. $this->assertSame(0,DB::table('share_tree')->where('ancestor_user_id',$id)->where('descendant_user_id',$id)->value('depth'));
  639. $this->assertSame('0.0000000000',$this->amount($id));
  640. $this->assertSame(8,DB::table('share_members')->count());
  641. }
  642. }