app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory()); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); }); DB::table('config')->insert([ ['field' => 'rebate', 'val' => '0.005'], ['field' => 'huishui_restriction', 'val' => '100'], ['field' => 'huishui_percentage', 'val' => '0.05'], ]); Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('member_id')->unique(); $t->integer('level')->default(1); $t->string('username')->default(''); $t->string('first_name')->default(''); $t->string('language')->nullable()->default('zh'); }); Schema::create('agents', function (Blueprint $t) { $t->id(); }); Schema::create('operation_audits', function (Blueprint $t) { $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes'); $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->dateTime('created_at'); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_110000_create_site_messages.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up(); Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->string('level_name'); }); DB::table('level')->insert([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']]); Schema::create('share_bet_facts', function (Blueprint $t) { $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64); $t->string('category', 32); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable(); foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0); $t->boolean('settled')->default(false); $t->boolean('voided')->default(false); $t->string('currency', 8)->default('CNY'); $t->string('source_version', 64)->default(''); $t->timestamps(); }); Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); }); Schema::create('wallet_bonus', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->integer('type')->default(0); $t->unsignedBigInteger('activity_id')->default(0); $t->unsignedBigInteger('related_id'); foreach (['amount', 'need_flow', 'remaind_flow'] as $c) $t->decimal($c, 24, 2); $t->integer('status'); $t->timestamps(); }); Schema::create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps(); }); DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '20.00']); Schema::create('balance_logs', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('change_type'); $t->unsignedBigInteger('related_id'); foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10); $t->string('remark'); $t->timestamps(); }); Schema::create('rebates', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('date'); $t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2); $t->decimal('rebate_ratio', 12, 4)->default(0); $t->decimal('amount', 24, 2)->default(0); $t->decimal('profit', 24, 2)->default(0); $t->string('username')->default(''); $t->string('first_name')->default(''); $t->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps(); }); Schema::create('backflow', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->string('date'); foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) $t->decimal($field, 24, 4)->default(0); $t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps(); }); $this->migration()->up(); $this->rulesMigration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php')->up(); DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]); DB::table('backflow_rules')->insert(['category' => 'self:lottery', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]); $this->request('/admin/rebate'); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php'; } private function rulesMigration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_160000_create_reward_rules.php'; } private function request(string $path, array $params = [], string $method = 'GET'): void { $request = Request::create($path, $method, $params); $request->user = (object) ['id' => 1, 'username' => 'operator-one']; $this->app->instance('request', $request); } private function user(string $memberId, int $level = 1): int { DB::table('users')->insertOrIgnore(['member_id' => $memberId, 'level' => $level]); return (int) DB::table('users')->where('member_id', $memberId)->value('id'); } /** Legacy PC28 rows: no facts, so the stored effective bet is the payout basis. */ private function rebate(array $values = []): int { $this->user((string) ($values['member_id'] ?? '1001')); return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23', 'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']); } /** The month's lottery loss mirrors the old |recharge + withdrawal| fixture. */ private function backflow(array $values = []): int { $values += ['member_id' => '1001', 'date' => '2026-09', 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00']; if (($values['status'] ?? 0) === 0) { $loss = bcadd((string) $values['recharge_amount'], (string) $values['withdrawal_amount'], 2); $this->fact($values['member_id'], 'self:lottery', $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2)); } return DB::table('backflow')->insertGetId($values); } private function fact(string $memberId, string $category, string $betAt, string $effective, string $profit, array $values = []): void { static $sequence = 0; DB::table('share_bet_facts')->insert($values + ['source' => 'test', 'source_id' => (string) ++$sequence, 'user_id' => $this->user($memberId), 'member_id' => $memberId, 'category' => $category, 'bet_at' => $betAt, 'bet_date' => substr($betAt, 0, 10), 'bet_amount' => $effective, 'effective_amount' => $effective, 'net_profit' => $profit, 'settled' => 1, 'voided' => 0, 'currency' => 'CNY', 'created_at' => now()->format('Y-m-d H:i:s'), 'updated_at' => now()->format('Y-m-d H:i:s')]); } private function enable(string $type, string $time = '14:30'): void { RewardGrantSettingsService::save($type, ['auto_enabled' => 1, 'grant_time' => $time]); } private function runRewards(): array { return (new AutomaticRewardGrantService())->run(); } #[DataProvider('payoutMessageCases')] public function test_manual_and_automatic_payouts_deliver_in_the_users_language(string $type, bool $automatic, ?string $language): void { $id = $type === 'rebate' ? $this->rebate() : $this->backflow(); DB::table('users')->where('member_id', '1001')->update(['language' => $language]); if ($automatic) { $this->enable($type); $this->assertSame(1, $this->runRewards()[$type]['granted']); $this->assertSame(0, $this->runRewards()[$type]['granted']); } else { $this->request('/admin/' . $type, ['id' => $id], 'POST'); $controller = $type === 'rebate' ? new RebateController() : new BackflowController(); $method = $type === 'rebate' ? 'store' : 'grant'; $this->assertSame(0, $controller->$method()->getData(true)['code']); $this->assertSame(-3, $controller->$method()->getData(true)['code']); } $memberId = (int) DB::table('users')->where('member_id', '1001')->value('id'); $language = in_array($language, ['zh', 'en', 'vi'], true) ? $language : 'zh'; $titles = $type === 'rebate' ? ['zh' => '笔笔返到账通知', 'en' => 'Bet rebate credited', 'vi' => 'Hoàn cược đã được ghi có'] : ['zh' => '回水到账通知', 'en' => 'Loss rebate credited', 'vi' => 'Hoàn lỗ đã được ghi có']; $inbox = new SiteInboxService(); $this->assertSame(1, $inbox->unread('user', $memberId)['count']); $message = $inbox->detail('user', $memberId, (int) DB::table('site_messages')->value('id')); $this->assertSame($language, $message['language']); $this->assertSame($titles[$language], $message['title']); $this->assertStringContainsString($type === 'rebate' ? '4.00' : '35.00', $message['content']); $this->assertStringContainsString($type === 'rebate' ? '2026-10-23' : '2026-09', $message['content']); $this->assertSame('internal', $message['category']); $this->assertSame(0, $inbox->unread('user', 1001)['count']); $this->assertSame(0, $inbox->unread('agent', $memberId)['count']); $this->assertSame(1, DB::table('site_messages')->count()); $this->assertSame(1, DB::table('site_message_recipients')->count()); $this->assertSame(1, DB::table('site_realtime_events')->count()); $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'site_message')->count()); } public static function payoutMessageCases(): array { $cases = []; foreach (['rebate', 'backflow'] as $type) foreach ([false, true] as $automatic) foreach (['zh', 'en', 'vi', null, '', 'unsupported'] as $language) { $cases[$type . '-' . (int) $automatic . '-' . ($language ?? 'null')] = [$type, $automatic, $language]; } return $cases; } public function test_melbet_message_lists_returns_the_rebate_and_backflow_notices(): void { $rebate = $this->rebate(); $backflow = $this->backflow(); Schema::table('users', function (Blueprint $t) { $t->string('user_id')->nullable(); $t->dateTime('delete_time')->nullable(); }); DB::table('users')->where('member_id', '1001')->update(['user_id' => 'login-1001', 'language' => 'en']); RewardGrantService::grant('rebate', $rebate, 'operator-one'); RewardGrantService::grant('backflow', $backflow, 'operator-one'); $snapshot = tempnam(sys_get_temp_dir(), 'reward-inbox-'); unlink($snapshot); // VACUUM INTO requires a new file. try { DB::statement('VACUUM INTO ?', [$snapshot]); $probe = new \Symfony\Component\Process\Process([PHP_BINARY, '-d', 'xdebug.mode=off', dirname(__DIR__) . '/fixtures/reward_message_inbox_probe.php', $snapshot, 'login-1001']); $probe->mustRun(); $result = json_decode($probe->getOutput(), true, 512, JSON_THROW_ON_ERROR); $this->assertSame(2, $result['listing']['count']); $this->assertEqualsCanonicalizing(['Bet rebate credited', 'Loss rebate credited'], array_column($result['listing']['list'], 'title')); $this->assertSame(['en', 'en'], array_column($result['listing']['list'], 'language')); $this->assertSame([false, false], array_column($result['listing']['list'], 'is_read')); $this->assertCount(2, $result['details']); } finally { if (is_file($snapshot)) unlink($snapshot); } } public function test_message_failure_rolls_back_the_payout_and_retry_delivers_once(): void { $id = $this->rebate(); $this->enable('rebate'); DB::statement("CREATE TRIGGER reject_reward_notice BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'notice unavailable'); END"); $this->assertSame(1, $this->runRewards()['rebate']['failed']); $this->assertEquals(20, DB::table('wallets')->value('available_balance')); $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status')); foreach (['balance_logs', 'site_messages', 'site_message_translations', 'site_message_recipients', 'site_realtime_events', 'operation_audits'] as $table) { $this->assertSame(0, DB::table($table)->count(), $table); } DB::statement('DROP TRIGGER reject_reward_notice'); $this->assertSame(1, $this->runRewards()['rebate']['granted']); $this->assertSame(0, $this->runRewards()['rebate']['granted']); $this->assertEquals(24, DB::table('wallets')->value('available_balance')); $this->assertSame(1, DB::table('site_message_recipients')->count()); } private function scheduledEvents(): array { $this->app['config']->set('cache', ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']]]); $this->app->register(\Illuminate\Cache\CacheServiceProvider::class); $schedule = new \Illuminate\Console\Scheduling\Schedule('Asia/Shanghai'); $kernel = new \App\Console\Kernel($this->app, new Dispatcher($this->app)); $method = new \ReflectionMethod($kernel, 'schedule'); $method->setAccessible(true); $method->invoke($kernel, $schedule); return $schedule->events(); } public function test_console_scheduler_registers_the_automatic_payout_command_every_minute_with_overlap_protection(): void { $events = array_values(array_filter($this->scheduledEvents(), fn ($event) => str_contains($event->command ?? '', 'rewards:auto-grant'))); $this->assertCount(1, $events); $this->assertSame('* * * * *', $events[0]->expression); $this->assertTrue($events[0]->withoutOverlapping); $this->assertSame(60, $events[0]->expiresAt); } public function test_console_scheduler_refreshes_reward_bases_and_wagers_after_bet_facts(): void { $commands = ['share:sync-facts --write', 'rewards:sync-bases', 'wagering:sync']; $scheduled = []; foreach ($this->scheduledEvents() as $event) { foreach ($commands as $command) { if (!str_ends_with($event->command ?? '', ' ' . $command)) continue; $this->assertSame('* * * * *', $event->expression); $this->assertTrue($event->withoutOverlapping); $scheduled[] = $command; } } $this->assertSame($commands, $scheduled); } public function test_migration_defaults_to_manual_and_both_settings_routes_keep_admin_and_existing_grant_permissions(): void { $id = $this->rebate(); $this->backflow(); foreach (['rebate', 'backflow'] as $type) { $this->assertSame(['type' => $type, 'auto_enabled' => 0, 'grant_time' => '03:00', 'timezone' => 'Asia/Shanghai'], RewardGrantSettingsService::get($type)); } $this->assertSame(0, $this->runRewards()['rebate']['granted']); $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status')); $this->assertSame(0, DB::table('balance_logs')->count()); $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) { foreach (['GET', 'POST'] as $method) { $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/grant-settings', $method)); $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware()); $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']); } } $this->migration()->down(); $this->assertFalse(Schema::hasTable('reward_grant_settings')); } public function test_settings_api_persists_independent_times_and_disabling_preserves_the_previous_time_without_paying(): void { $this->rebate(); $this->request('/admin/rebate/grant-settings', ['auto_enabled' => true, 'grant_time' => '14:30'], 'POST'); $body = (new RewardGrantSettings())->saveRebate()->getData(true); $this->assertSame(0, $body['code']); $this->assertSame(1, $body['data']['auto_enabled']); $this->assertSame('14:30', $body['data']['grant_time']); $this->assertSame(0, RewardGrantSettingsService::get('backflow')['auto_enabled']); $this->request('/admin/backflow/grant-settings', ['auto_enabled' => 1, 'grant_time' => '20:15'], 'POST'); $this->assertSame(0, (new RewardGrantSettings())->saveBackflow()->getData(true)['code']); $this->assertSame('20:15', RewardGrantSettingsService::get('backflow')['grant_time']); $this->request('/admin/rebate/grant-settings', ['auto_enabled' => 0], 'POST'); $body = (new RewardGrantSettings())->saveRebate()->getData(true); $this->assertSame(0, $body['data']['auto_enabled']); $this->assertSame('14:30', $body['data']['grant_time']); $this->assertSame(0, DB::table('balance_logs')->count()); $this->assertSame(0, (new RewardGrantSettings())->rebate()->getData(true)['code']); } #[DataProvider('invalidSettings')] public function test_invalid_settings_do_not_change_the_switch_or_time(array $values): void { $this->request('/admin/rebate/grant-settings', $values, 'POST'); $this->assertSame(-3, (new RewardGrantSettings())->saveRebate()->getData(true)['code']); $this->assertSame(0, RewardGrantSettingsService::get('rebate')['auto_enabled']); $this->assertSame('03:00', RewardGrantSettingsService::get('rebate')['grant_time']); } public static function invalidSettings(): array { return [[[]], [['auto_enabled' => 2]], [['auto_enabled' => 'auto']], [['auto_enabled' => 1]], [['auto_enabled' => true]], [['auto_enabled' => 1, 'grant_time' => '']], [['auto_enabled' => 1, 'grant_time' => '24:00']], [['auto_enabled' => 1, 'grant_time' => '9:00']], [['auto_enabled' => 1, 'grant_time' => '12:60']], [['auto_enabled' => 0, 'grant_time' => ['03:00']]]]; } public function test_exact_minute_pays_both_types_once_and_retries_do_not_duplicate_wallet_or_ledger(): void { $rebate = $this->rebate(); $backflow = $this->backflow(); $this->rebate(['date' => '2026-10-25']); $this->backflow(['date' => '2026-10']); $this->rebate(['status' => 1]); $this->backflow(['status' => 1]); $this->enable('rebate'); $this->enable('backflow'); Carbon::setTestNow(Carbon::parse('2026-10-25 14:29:59', 'Asia/Shanghai')); $this->assertSame(0, $this->runRewards()['rebate']['granted']); $this->assertSame(0, DB::table('balance_logs')->count()); Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai')); $result = $this->runRewards(); $this->assertSame(1, $result['rebate']['granted']); $this->assertSame(1, $result['backflow']['granted']); $this->assertEquals(59, DB::table('wallets')->value('available_balance')); $this->assertEquals(4, DB::table('rebates')->where('id', $rebate)->value('amount')); $this->assertEquals(35, DB::table('backflow')->where('id', $backflow)->value('amount')); $this->assertSame('system:auto-grant', DB::table('rebates')->where('id', $rebate)->value('audited_by')); $this->assertSame(now()->timestamp, DB::table('backflow')->where('id', $backflow)->value('grant_time')); $this->assertSame(2, DB::table('balance_logs')->count()); $this->assertSame(0, $this->runRewards()['rebate']['granted']); $this->assertSame(2, DB::table('balance_logs')->count()); $this->assertEquals(59, DB::table('wallets')->value('available_balance')); } public function test_scheduled_cutoffs_keep_new_york_days_and_shanghai_months_even_when_catching_up_later(): void { Carbon::setTestNow(Carbon::parse('2026-11-01 03:00:00', 'Asia/Shanghai')); $old = $this->rebate(['date' => '2026-10-30']); $notYetEndedAtSchedule = $this->rebate(['date' => '2026-10-31']); $lastMonth = $this->backflow(['date' => '2026-10']); $currentMonth = $this->backflow(['date' => '2026-11']); $this->enable('rebate', '03:00'); $this->enable('backflow', '03:00'); // At 14:00 the New York day has changed, but this run still belongs to the 03:00 schedule. Carbon::setTestNow(Carbon::parse('2026-11-01 14:00:00', 'Asia/Shanghai')); $this->runRewards(); $this->assertSame(1, DB::table('rebates')->where('id', $old)->value('status')); $this->assertSame(0, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status')); $this->assertSame(1, DB::table('backflow')->where('id', $lastMonth)->value('status')); $this->assertSame(0, DB::table('backflow')->where('id', $currentMonth)->value('status')); Carbon::setTestNow(Carbon::parse('2026-11-02 02:59:59', 'Asia/Shanghai')); $this->assertSame(0, $this->runRewards()['rebate']['granted']); Carbon::setTestNow(Carbon::parse('2026-11-02 03:00:00', 'Asia/Shanghai')); $this->assertSame(1, $this->runRewards()['rebate']['granted']); $this->assertSame(1, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status')); } public function test_disabled_switch_stops_auto_but_both_manual_controllers_still_work_and_stale_calls_cannot_pay_twice(): void { $id = $this->rebate(); $backflow = $this->backflow(); $stale = Rebate::findOrFail($id); $this->enable('rebate'); RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]); $this->assertSame(0, $this->runRewards()['rebate']['granted']); $this->request('/admin/rebate/submit', ['id' => $id], 'POST'); $this->assertSame(0, (new RebateController())->store()->getData(true)['code']); $this->assertSame(-3, (new RebateController())->store()->getData(true)['code']); $this->assertSame('operator-one', DB::table('rebates')->where('id', $id)->value('audited_by')); $this->request('/admin/backflow/grant', ['id' => $backflow], 'POST'); $this->assertSame(0, (new BackflowController())->grant()->getData(true)['code']); $this->assertSame(-3, (new BackflowController())->grant()->getData(true)['code']); $this->enable('rebate'); $this->enable('backflow'); $this->runRewards(); $this->assertFalse(RewardGrantService::grant('rebate', $id, 'auto', true)); $this->assertSame(2, DB::table('balance_logs')->count()); try { RebateService::BibiReturn($stale, '999999'); $this->fail('A stale row must not pay again'); } catch (\RuntimeException $e) { $this->assertSame(-3, $e->getCode()); } $this->assertEquals(59, DB::table('wallets')->value('available_balance')); } public function test_payout_reads_fresh_effective_bet_and_zero_rewards_are_finalized_without_ledger_credits(): void { $id = $this->rebate(); $stale = Rebate::findOrFail($id); DB::table('rebates')->where('id', $id)->update(['effective_betting_amount' => 100]); RebateService::BibiReturn($stale, '800'); $this->assertEquals(0.5, DB::table('rebates')->where('id', $id)->value('amount')); $this->rebate(['effective_betting_amount' => 0]); $below = $this->backflow(['recharge_amount' => 50, 'withdrawal_amount' => 0]); $this->enable('rebate'); $this->enable('backflow'); $this->runRewards(); $this->assertSame(1, DB::table('backflow')->where('id', $below)->value('status')); $this->assertEquals(0, DB::table('backflow')->where('id', $below)->value('amount')); $this->assertSame(1, DB::table('balance_logs')->count()); $this->assertEquals(20.5, DB::table('wallets')->value('available_balance')); $this->assertSame(1, DB::table('site_messages')->count(), 'Zero payouts do not send notices'); } public function test_ledger_failure_rolls_back_wallet_and_reward_then_automatic_retry_credits_once(): void { $id = $this->rebate(); $this->enable('rebate'); DB::statement("CREATE TRIGGER reject_reward_ledger BEFORE INSERT ON bot_balance_logs BEGIN SELECT RAISE(ABORT, 'ledger unavailable'); END"); $this->assertSame(1, $this->runRewards()['rebate']['failed']); $this->assertEquals(20, DB::table('wallets')->value('available_balance')); $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status')); $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('grant_time')); $this->assertSame(0, DB::table('balance_logs')->count()); $this->assertSame(0, DB::table('site_messages')->count()); DB::statement('DROP TRIGGER reject_reward_ledger'); $this->assertSame(1, $this->runRewards()['rebate']['granted']); $this->assertEquals(24, DB::table('wallets')->value('available_balance')); $this->assertSame(1, DB::table('balance_logs')->count()); } public function test_status_save_failure_rolls_back_backflow_wallet_and_ledger_and_missing_wallet_does_not_stop_other_rows(): void { $id = $this->backflow(); $this->enable('backflow'); DB::statement("CREATE TRIGGER reject_backflow_status BEFORE UPDATE ON bot_backflow BEGIN SELECT RAISE(ABORT, 'reward unavailable'); END"); $this->assertSame(1, $this->runRewards()['backflow']['failed']); $this->assertSame(0, DB::table('backflow')->where('id', $id)->value('status')); $this->assertSame(0, DB::table('balance_logs')->count()); $this->assertEquals(20, DB::table('wallets')->value('available_balance')); DB::statement('DROP TRIGGER reject_backflow_status'); $bad = $this->rebate(['member_id' => 'no-wallet']); $good = $this->rebate(); $this->enable('rebate'); $result = $this->runRewards(); $this->assertSame(1, $result['rebate']['failed']); $this->assertSame(1, $result['rebate']['granted']); $this->assertSame(0, DB::table('rebates')->where('id', $bad)->value('status')); $this->assertSame(1, DB::table('rebates')->where('id', $good)->value('status')); $this->assertSame(1, $result['backflow']['granted']); } public function test_switch_and_time_are_rechecked_between_records_and_before_retrying(): void { $this->rebate(); $second = $this->rebate(); $this->enable('rebate'); $changed = false; $this->app['db']->connection()->listen(function ($event) use (&$changed) { if (!$changed && str_starts_with(strtolower($event->sql), 'update "bot_rebates"')) { $changed = true; RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]); } }); $this->assertSame(1, $this->runRewards()['rebate']['granted']); $this->assertSame(0, DB::table('rebates')->where('id', $second)->value('status')); $this->assertSame(1, DB::table('balance_logs')->count()); $this->enable('rebate', '15:00'); $this->assertSame(0, $this->runRewards()['rebate']['granted']); Carbon::setTestNow(Carbon::parse('2026-10-25 15:00:00', 'Asia/Shanghai')); $this->assertSame(1, $this->runRewards()['rebate']['granted']); } public function test_chunked_processing_does_not_skip_rows_and_command_reports_failure_without_forcing_disabled_rewards(): void { for ($i = 0; $i < 105; ++$i) $this->rebate(['effective_betting_amount' => 0]); $command = new AutoGrantRewards(); $command->setLaravel($this->app); $tester = new CommandTester($command); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertSame(105, DB::table('rebates')->where('status', 0)->count()); $this->enable('rebate'); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('"granted":105', $tester->getDisplay()); $this->assertSame(105, DB::table('rebates')->where('status', 1)->count()); $this->rebate(['member_id' => 'missing']); $tester->execute([]); $this->assertSame(1, $tester->getStatusCode()); } public function test_rules_api_lists_categories_and_levels_replaces_one_category_and_rejects_bad_tiers(): void { $body = (new RewardRules())->rebate()->getData(true); $this->assertSame(0, $body['code']); $this->assertContains(['value' => 'third:1', 'label' => '真人'], $body['data']['categories']); $this->assertSame([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']], $body['data']['levels']); $this->assertSame([], $body['data']['rules']['third:2']); $this->request('/admin/rebate/rules', ['category' => 'third:2', 'rules' => [ ['level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3], ['level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0], ]], 'POST'); $body = (new RewardRules())->saveRebate()->getData(true); $this->assertSame(0, $body['code']); $this->assertSame(['0.00', '1000.00'], array_column($body['data']['rules']['third:2'], 'min_effective_amount')); $this->assertSame('3.00', $body['data']['rules']['third:2'][1]['turnover_multiple']); $this->assertCount(1, $body['data']['rules']['self:lottery']); foreach ([['category' => 'third:9', 'rules' => []], ['category' => 'third:2', 'rules' => [['level' => 7, 'min_effective_amount' => 0, 'percentage' => 1, 'turnover_multiple' => 0]]], ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => '1e3', 'percentage' => 1, 'turnover_multiple' => 0]]], ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 0, 'percentage' => 101, 'turnover_multiple' => 0]]], ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 5, 'percentage' => 1, 'turnover_multiple' => 0], ['level' => 1, 'min_effective_amount' => 5, 'percentage' => 2, 'turnover_multiple' => 0]]]] as $bad) { $this->request('/admin/rebate/rules', $bad, 'POST'); $this->assertSame(-3, (new RewardRules())->saveRebate()->getData(true)['code']); } $this->assertSame(2, DB::table('rebate_rules')->where('category', 'third:2')->count()); $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => [ ['min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2]]], 'POST'); $body = (new RewardRules())->saveBackflow()->getData(true); $this->assertSame([['id' => 2, 'category' => 'third:1', 'min_loss_amount' => '2000.00', 'reward_amount' => '60.00', 'turnover_multiple' => '2.00']], $body['data']['rules']['third:1']); $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => []], 'POST'); $this->assertSame(0, (new RewardRules())->saveBackflow()->getData(true)['code']); $this->assertSame(0, DB::table('backflow_rules')->where('category', 'third:1')->count()); $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) { foreach (['GET', 'POST'] as $method) { $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/rules', $method)); $this->assertContains('check.button.uri', $route->middleware()); $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']); } } } public function test_rebate_sums_every_category_by_new_york_day_and_uses_the_members_level_tier_and_turnover(): void { $this->user('2001', 2); DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']); DB::table('rebate_rules')->insert([ ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0], ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3], ['category' => 'third:2', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '2', 'turnover_multiple' => 0], ['category' => 'self:sport', 'level' => 2, 'min_effective_amount' => 500, 'percentage' => '1', 'turnover_multiple' => 0], ]); // New York 2026-10-23 is Shanghai 2026-10-23 12:00 to 2026-10-24 11:59:59. $this->fact('2001', 'third:2', '2026-10-23 20:00:00', '1000', '-1000'); $this->fact('2001', 'third:2', '2026-10-24 10:00:00', '500', '500'); $this->fact('2001', 'self:sport', '2026-10-24 11:00:00', '300', '-300'); $this->fact('2001', 'third:2', '2026-10-24 11:30:00', '9999', '0', ['voided' => 1]); $this->fact('2001', 'third:2', '2026-10-24 13:00:00', '7777', '-7777'); $this->assertSame(['rebates' => 2, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run()); $row = DB::table('rebates')->where('member_id', '2001')->where('date', '2026-10-23')->first(); $this->assertEquals(1800, $row->betting_amount); $this->assertEquals(1800, $row->effective_betting_amount); $this->assertEquals(15, $row->amount); $this->assertEquals(45, $row->need_flow); $this->assertSame(['self:sport', 'third:2'], array_column(json_decode($row->details, true), 'category')); $this->assertTrue(RewardGrantService::grant('rebate', (int) $row->id, 'operator-one')); $this->assertEquals(15, DB::table('wallets')->where('member_id', '2001')->value('available_balance')); $bonus = DB::table('wallet_bonus')->first(); $this->assertSame('2001', $bonus->member_id); $this->assertEquals(45, $bonus->need_flow); $this->assertEquals(15, $bonus->amount); $this->assertSame((int) $row->id, (int) $bonus->related_id); $this->assertStringContainsString('电子:1500.00×1.0000%', DB::table('balance_logs')->value('remark')); // A paid day is never rewritten by later facts. $this->fact('2001', 'third:2', '2026-10-24 09:00:00', '100', '-100'); (new RewardBaseSyncService())->run(); $this->assertEquals(1800, DB::table('rebates')->where('id', $row->id)->value('effective_betting_amount')); $this->assertSame(1, DB::table('wallet_bonus')->count()); } public function test_backflow_pays_fixed_rewards_for_each_categorys_monthly_net_loss(): void { $this->user('2001'); DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']); DB::table('backflow_rules')->insert([ ['category' => 'third:1', 'min_loss_amount' => 1000, 'reward_amount' => 20, 'turnover_multiple' => 0], ['category' => 'third:1', 'min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2], ['category' => 'third:2', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0], ['category' => 'self:sport', 'min_loss_amount' => 100, 'reward_amount' => 5, 'turnover_multiple' => 0], ]); $this->fact('2001', 'third:1', '2026-09-03 10:00:00', '2000', '-2000'); $this->fact('2001', 'third:1', '2026-09-28 10:00:00', '500', '-500'); $this->fact('2001', 'third:2', '2026-09-05 10:00:00', '100', '100'); $this->fact('2001', 'self:sport', '2026-09-30 23:00:00', '50', '-50'); $this->fact('2001', 'self:sport', '2026-10-01 00:30:00', '900', '-900'); (new RewardBaseSyncService())->run(); $row = DB::table('backflow')->where('member_id', '2001')->where('date', '2026-09')->first(); $this->assertEquals(2550, $row->loss_amount); $this->assertEquals(60, $row->amount); $this->assertTrue(RewardGrantService::grant('backflow', (int) $row->id, 'operator-one')); $this->assertEquals(60, DB::table('wallets')->where('member_id', '2001')->value('available_balance')); $this->assertEquals(120, DB::table('wallet_bonus')->value('need_flow')); $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow')); } public function test_backflow_can_be_granted_again_for_the_remainder_until_the_month_is_closed(): void { // now = 2026-10-25 14:30 上海;当月 2026-10。钱包初始 20。 DB::table('backflow_rules')->insert([ ['category' => 'self:lottery', 'min_loss_amount' => 10, 'reward_amount' => 8, 'turnover_multiple' => 1], ['category' => 'self:lottery', 'min_loss_amount' => 300, 'reward_amount' => 50, 'turnover_multiple' => 1], ]); $id = $this->backflow(['date' => '2026-10', 'recharge_amount' => '50.00', 'withdrawal_amount' => '0']); // 亏 50 $this->enable('backflow'); $this->assertSame(0, $this->runRewards()['backflow']['granted'], '自动发放不发当月'); // 第一次手动发放:当月亏 50 → 8,记为部分发放 $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-one')); $row = DB::table('backflow')->where('id', $id)->first(); $this->assertSame([2, '8.00', '8.00'], [(int) $row->status, bcadd($row->amount, 0, 2), bcadd($row->granted_amount, 0, 2)]); $this->assertEquals(28, DB::table('wallets')->where('member_id', '1001')->value('available_balance')); $this->assertSame(['amount' => '8.00', 'granted_amount' => '8.00', 'pending_amount' => '0.00', 'can_grant' => false, 'status_text' => '部分发放'], BackflowService::grantFields((array) $row)); // 没有差额时当月不能再发 try { RewardGrantService::grant('backflow', $id, 'operator-one'); $this->fail('expected exception'); } catch (\RuntimeException $e) { $this->assertSame('暂无可发放金额(按当前规则应发 8.00,已发放 8.00)', $e->getMessage()); } // 继续亏到 350:同步后应发 50,已发 8,待发 42 $this->fact('1001', 'self:lottery', '2026-10-20 12:00:00', '300', '-300'); (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20'); $fields = BackflowService::grantFields((array) DB::table('backflow')->where('id', $id)->first()); $this->assertSame(['50.00', '8.00', '42.00', true], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant']]); $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-two')); $row = DB::table('backflow')->where('id', $id)->first(); $this->assertSame([2, '50.00', '50.00'], [(int) $row->status, bcadd($row->granted_amount, 0, 2), bcadd($row->granted_need_flow, 0, 2)]); $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance')); $this->assertSame(['8.0000000000', '42.0000000000'], DB::table('balance_logs')->where('change_type', '回水')->orderBy('id')->pluck('amount') ->map(fn ($v) => bcadd($v, 0, 10))->all()); $this->assertStringContainsString('本次发放:42.00;累计发放:50.00', DB::table('balance_logs')->where('change_type', '回水')->orderByDesc('id')->value('remark')); $contents = DB::table('site_message_translations')->where('language', 'zh')->orderBy('id')->pluck('content')->all(); $this->assertCount(2, $contents); $this->assertStringContainsString('本次已发放 8.00', $contents[0]); $this->assertStringContainsString('本次已发放 42.00', $contents[1]); // 下个月自动发放:无差额,只结清为已发放,不重复入账 Carbon::setTestNow(Carbon::parse('2026-11-01 14:30:00', 'Asia/Shanghai')); $this->assertSame(1, $this->runRewards()['backflow']['granted']); $this->assertSame(1, (int) DB::table('backflow')->where('id', $id)->value('status')); $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance')); $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count()); $this->assertSame(2, DB::table('site_messages')->count(), 'Closing without another credit must not send a notice'); // 未来日期仍不能提前发放。 $rebate = $this->rebate(['date' => Carbon::now('America/New_York')->addDay()->format('Y-m-d')]); $this->expectExceptionMessage('未到发放时间'); RewardGrantService::grant('rebate', $rebate, 'operator-one'); } public function test_manual_rebate_pays_3033_basis_then_differences_and_auto_finishes_after_day_end(): void { DB::table('rebate_rules')->update(['percentage' => 1, 'turnover_multiple' => 2]); $id = $this->rebate(['date' => '2026-10-25']); $this->fact('1001', 'self:lottery', '2026-10-25 13:00:00', '3033', '-3033'); $this->enable('rebate'); $this->assertSame(0, $this->runRewards()['rebate']['granted'], 'Automatic payout still waits for day end'); $this->request('/admin/rebate/submit', ['id' => $id], 'POST'); $controller = new RebateController(); $this->assertSame(0, $controller->store()->getData(true)['code']); $row = DB::table('rebates')->where('id', $id)->first(); $this->assertSame(2, (int) $row->status); $this->assertEquals(30.33, $row->granted_amount); $this->assertEquals(60.66, $row->granted_need_flow); $this->assertEquals(50.33, DB::table('wallets')->value('available_balance')); $this->assertFalse(RebateService::grantFields((array) $row)['can_grant']); $this->assertSame(-3, $controller->store()->getData(true)['code'], 'No difference cannot pay twice'); $this->fact('1001', 'self:lottery', '2026-10-25 14:00:00', '1000', '-1000'); (new RewardBaseSyncService())->run('2026-10-25', '2026-10-25'); $fields = RebateService::grantFields((array) DB::table('rebates')->where('id', $id)->first()); $this->assertSame(['40.33', '30.33', '10.00', true, '部分发放'], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant'], $fields['status_text']]); DB::statement("CREATE TRIGGER reject_partial_notice BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'notice unavailable'); END"); $this->assertNotSame(0, $controller->store()->getData(true)['code']); $this->assertEquals(50.33, DB::table('wallets')->value('available_balance')); $this->assertEquals(30.33, DB::table('rebates')->where('id', $id)->value('granted_amount')); $this->assertSame(1, DB::table('site_messages')->count()); $this->assertSame(1, DB::table('wallet_bonus')->count()); DB::statement('DROP TRIGGER reject_partial_notice'); $this->assertSame(0, $controller->store()->getData(true)['code']); $this->assertEquals(60.33, DB::table('wallets')->value('available_balance')); $this->assertEquals(80.66, DB::table('wallet_bonus')->sum('need_flow')); $this->assertSame(['30.33', '10.00'], DB::table('balance_logs')->orderBy('id')->pluck('amount')->map(fn ($v) => bcadd($v, '0', 2))->all()); $contents = DB::table('site_message_translations')->where('language', 'zh')->orderBy('id')->pluck('content')->all(); $this->assertStringContainsString('本次已发放 30.33', $contents[0]); $this->assertStringContainsString('本次已发放 10.00', $contents[1]); $this->fact('1001', 'self:lottery', '2026-10-25 14:20:00', '333', '-333'); (new RewardBaseSyncService())->run('2026-10-25', '2026-10-25'); Carbon::setTestNow(Carbon::parse('2026-10-26 14:30:00', 'Asia/Shanghai')); $this->assertSame(1, $this->runRewards()['rebate']['granted']); $row = DB::table('rebates')->where('id', $id)->first(); $this->assertSame(1, (int) $row->status); $this->assertEquals(43.66, $row->granted_amount); $this->assertEquals(87.32, $row->granted_need_flow); $this->assertEquals(63.66, DB::table('wallets')->value('available_balance')); $this->assertSame(3, DB::table('site_messages')->count()); $this->assertSame(0, $this->runRewards()['rebate']['granted']); $this->assertSame(3, DB::table('balance_logs')->count()); } public function test_rebate_day_end_without_a_difference_does_not_credit_or_notify_again(): void { $id = $this->rebate(['date' => '2026-10-25']); $this->assertTrue(RewardGrantService::grant('rebate', $id, 'operator-one')); DB::table('rebate_rules')->update(['percentage' => '0.1', 'turnover_multiple' => 3]); Carbon::setTestNow(Carbon::parse('2026-10-26 14:30:00', 'Asia/Shanghai')); $this->enable('rebate'); $this->assertSame(1, $this->runRewards()['rebate']['granted']); $row = DB::table('rebates')->where('id', $id)->first(); $this->assertSame(1, (int) $row->status); $this->assertEquals(4, $row->granted_amount, 'A lower calculation never reclaims a previous credit'); $this->assertEquals(0, $row->granted_need_flow, 'Unpaid turnover must not be counted as granted'); $this->assertEquals(24, DB::table('wallets')->value('available_balance')); $this->assertSame(1, DB::table('site_messages')->count()); $this->assertSame(1, DB::table('balance_logs')->count()); $this->assertFalse(RewardGrantService::grant('rebate', $id, 'operator-one', true)); } public function test_rebate_granted_amount_migration_preserves_paid_history_and_partial_totals(): void { $paid = $this->rebate(['date' => '2026-09-01', 'status' => 1, 'amount' => 30.33, 'need_flow' => 60.66]); $pending = $this->rebate(['date' => '2026-09-02', 'amount' => 99]); $partial = $this->rebate(['date' => '2026-10-25', 'status' => 2, 'amount' => 50, 'granted_amount' => 30.33, 'granted_need_flow' => 60.66]); $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php'; $migration->up(); $this->assertEquals(30.33, DB::table('rebates')->where('id', $paid)->value('granted_amount')); $this->assertEquals(60.66, DB::table('rebates')->where('id', $paid)->value('granted_need_flow')); $this->assertEquals(0, DB::table('rebates')->where('id', $pending)->value('granted_amount')); $this->assertEquals(30.33, DB::table('rebates')->where('id', $partial)->value('granted_amount')); $migration->down(); $this->assertSame(1, (int) DB::table('rebates')->where('id', $partial)->value('status')); $this->assertFalse(Schema::hasColumn('rebates', 'granted_amount')); } public function test_granted_amount_migration_backfills_paid_rows(): void { $paid = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-08', 'status' => 1, 'amount' => '35', 'need_flow' => '70', 'granted_amount' => 0]); $open = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-07', 'status' => 0, 'amount' => '35', 'granted_amount' => 0]); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up(); $this->assertEquals([35, 70], array_values((array) DB::table('backflow')->where('id', $paid)->first(['granted_amount', 'granted_need_flow']))); $this->assertEquals(0, DB::table('backflow')->where('id', $open)->value('granted_amount')); } public function test_sync_cursor_only_revisits_changed_facts_and_backfill_range_creates_missing_rows(): void { $this->fact('3001', 'third:1', '2026-10-20 13:00:00', '100', '-100', ['updated_at' => '2026-10-20 13:00:00']); $this->assertSame(0, (new RewardBaseSyncService())->run()['rebates']); $this->assertSame(['rebates' => 1, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20')); $this->assertEquals(100, DB::table('rebates')->where('member_id', '3001')->value('effective_betting_amount')); $this->assertNotNull(DB::table('share_system_state')->where('key', 'reward_bases_cursor')->value('value')); $this->expectException(\InvalidArgumentException::class); (new RewardBaseSyncService())->run('2026-10-20', null); } public function test_rules_migration_removes_the_three_global_settings_and_rollback_restores_them(): void { $this->assertSame(['huishui_restriction'], DB::table('config')->pluck('field')->all()); $this->assertTrue(Schema::hasColumns('rebates', ['details', 'need_flow'])); $this->rulesMigration()->down(); $this->assertEqualsCanonicalizing(['rebate', 'huishui_restriction', 'huishui_percentage'], DB::table('config')->pluck('field')->all()); $this->assertSame('0.005', DB::table('config')->where('field', 'rebate')->value('val')); $this->assertFalse(Schema::hasTable('rebate_rules')); $this->assertFalse(Schema::hasColumn('backflow', 'loss_amount')); } }