AutomaticRewardGrantTest.php 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Console\Commands\AutoGrantRewards;
  4. use App\Http\Controllers\admin\Backflow as BackflowController;
  5. use App\Http\Controllers\admin\Rebate as RebateController;
  6. use App\Http\Controllers\admin\RewardGrantSettings;
  7. use App\Http\Controllers\admin\RewardRules;
  8. use App\Models\Rebate;
  9. use App\Services\AutomaticRewardGrantService;
  10. use App\Services\BackflowService;
  11. use App\Services\RebateService;
  12. use App\Services\RewardBaseSyncService;
  13. use App\Services\RewardGrantService;
  14. use App\Services\RewardGrantSettingsService;
  15. use Carbon\Carbon;
  16. use Illuminate\Config\Repository;
  17. use Illuminate\Database\Capsule\Manager;
  18. use Illuminate\Database\Schema\Blueprint;
  19. use Illuminate\Events\Dispatcher;
  20. use Illuminate\Foundation\Application;
  21. use Illuminate\Http\JsonResponse;
  22. use Illuminate\Http\Request;
  23. use Illuminate\Routing\Router;
  24. use Illuminate\Support\Facades\DB;
  25. use Illuminate\Support\Facades\Facade;
  26. use Illuminate\Support\Facades\Schema;
  27. use Illuminate\Translation\ArrayLoader;
  28. use Illuminate\Translation\Translator;
  29. use Illuminate\Validation\Factory;
  30. use PHPUnit\Framework\Attributes\DataProvider;
  31. use PHPUnit\Framework\TestCase;
  32. use Symfony\Component\Console\Tester\CommandTester;
  33. /** Real payout controllers, command, wallets and ledgers; no project boot, .env or live database. */
  34. class AutomaticRewardGrantTest extends TestCase
  35. {
  36. private Application $app;
  37. protected function setUp(): void
  38. {
  39. parent::setUp();
  40. Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai'));
  41. Facade::clearResolvedInstances();
  42. $this->app = new Application(dirname(__DIR__, 2));
  43. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  44. Facade::setFacadeApplication($this->app);
  45. $db = new Manager($this->app);
  46. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  47. $db->setEventDispatcher(new Dispatcher($this->app));
  48. $db->setAsGlobal(); $db->bootEloquent();
  49. $this->app->instance('db', $db->getDatabaseManager());
  50. $this->app->bind('db.schema', fn () => $db->schema());
  51. $translator = new Translator(new ArrayLoader(), 'zh');
  52. $this->app->instance('translator', $translator);
  53. $this->app->instance('validator', new Factory($translator, $this->app));
  54. $this->app->instance('log', new \Psr\Log\NullLogger());
  55. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  56. Request::macro('validate', function (array $rules) {
  57. return app('validator')->make($this->all(), $rules)->validate();
  58. });
  59. Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); });
  60. DB::table('config')->insert([
  61. ['field' => 'rebate', 'val' => '0.005'],
  62. ['field' => 'huishui_restriction', 'val' => '100'],
  63. ['field' => 'huishui_percentage', 'val' => '0.05'],
  64. ]);
  65. Schema::create('users', function (Blueprint $t) {
  66. $t->id(); $t->string('member_id')->unique(); $t->integer('level')->default(1);
  67. $t->string('username')->default(''); $t->string('first_name')->default('');
  68. });
  69. Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->string('level_name'); });
  70. DB::table('level')->insert([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']]);
  71. Schema::create('share_bet_facts', function (Blueprint $t) {
  72. $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64);
  73. $t->string('category', 32); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
  74. foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
  75. $t->boolean('settled')->default(false); $t->boolean('voided')->default(false);
  76. $t->string('currency', 8)->default('CNY'); $t->string('source_version', 64)->default(''); $t->timestamps();
  77. });
  78. Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
  79. Schema::create('wallet_bonus', function (Blueprint $t) {
  80. $t->id(); $t->string('member_id'); $t->integer('type')->default(0); $t->unsignedBigInteger('activity_id')->default(0);
  81. $t->unsignedBigInteger('related_id'); foreach (['amount', 'need_flow', 'remaind_flow'] as $c) $t->decimal($c, 24, 2);
  82. $t->integer('status'); $t->timestamps();
  83. });
  84. Schema::create('wallets', function (Blueprint $t) {
  85. $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
  86. });
  87. DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '20.00']);
  88. Schema::create('balance_logs', function (Blueprint $t) {
  89. $t->id(); $t->string('member_id'); $t->string('change_type'); $t->unsignedBigInteger('related_id');
  90. foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10);
  91. $t->string('remark'); $t->timestamps();
  92. });
  93. Schema::create('rebates', function (Blueprint $t) {
  94. $t->id(); $t->string('member_id'); $t->string('date');
  95. $t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2);
  96. $t->decimal('rebate_ratio', 12, 4)->default(0); $t->decimal('amount', 24, 2)->default(0);
  97. $t->decimal('profit', 24, 2)->default(0); $t->string('username')->default(''); $t->string('first_name')->default('');
  98. $t->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps();
  99. });
  100. Schema::create('backflow', function (Blueprint $t) {
  101. $t->id(); $t->string('member_id'); $t->string('date');
  102. foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) $t->decimal($field, 24, 4)->default(0);
  103. $t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps();
  104. });
  105. $this->migration()->up();
  106. $this->rulesMigration()->up();
  107. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
  108. DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]);
  109. DB::table('backflow_rules')->insert(['category' => 'self:lottery', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
  110. $this->request('/admin/rebate');
  111. }
  112. protected function tearDown(): void
  113. {
  114. Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances();
  115. parent::tearDown();
  116. }
  117. private function migration()
  118. {
  119. return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php';
  120. }
  121. private function rulesMigration()
  122. {
  123. return require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_160000_create_reward_rules.php';
  124. }
  125. private function request(string $path, array $params = [], string $method = 'GET'): void
  126. {
  127. $request = Request::create($path, $method, $params);
  128. $request->user = (object) ['id' => 1, 'username' => 'operator-one'];
  129. $this->app->instance('request', $request);
  130. }
  131. private function user(string $memberId, int $level = 1): int
  132. {
  133. DB::table('users')->insertOrIgnore(['member_id' => $memberId, 'level' => $level]);
  134. return (int) DB::table('users')->where('member_id', $memberId)->value('id');
  135. }
  136. /** Legacy PC28 rows: no facts, so the stored effective bet is the payout basis. */
  137. private function rebate(array $values = []): int
  138. {
  139. $this->user((string) ($values['member_id'] ?? '1001'));
  140. return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23',
  141. 'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']);
  142. }
  143. /** The month's lottery loss mirrors the old |recharge + withdrawal| fixture. */
  144. private function backflow(array $values = []): int
  145. {
  146. $values += ['member_id' => '1001', 'date' => '2026-09', 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00'];
  147. if (($values['status'] ?? 0) === 0) {
  148. $loss = bcadd((string) $values['recharge_amount'], (string) $values['withdrawal_amount'], 2);
  149. $this->fact($values['member_id'], 'self:lottery', $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2));
  150. }
  151. return DB::table('backflow')->insertGetId($values);
  152. }
  153. private function fact(string $memberId, string $category, string $betAt, string $effective, string $profit, array $values = []): void
  154. {
  155. static $sequence = 0;
  156. DB::table('share_bet_facts')->insert($values + ['source' => 'test', 'source_id' => (string) ++$sequence,
  157. 'user_id' => $this->user($memberId), 'member_id' => $memberId, 'category' => $category, 'bet_at' => $betAt,
  158. 'bet_date' => substr($betAt, 0, 10), 'bet_amount' => $effective, 'effective_amount' => $effective,
  159. 'net_profit' => $profit, 'settled' => 1, 'voided' => 0, 'currency' => 'CNY',
  160. 'created_at' => now()->format('Y-m-d H:i:s'), 'updated_at' => now()->format('Y-m-d H:i:s')]);
  161. }
  162. private function enable(string $type, string $time = '14:30'): void
  163. {
  164. RewardGrantSettingsService::save($type, ['auto_enabled' => 1, 'grant_time' => $time]);
  165. }
  166. private function runRewards(): array { return (new AutomaticRewardGrantService())->run(); }
  167. private function scheduledEvents(): array
  168. {
  169. $this->app['config']->set('cache', ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']]]);
  170. $this->app->register(\Illuminate\Cache\CacheServiceProvider::class);
  171. $schedule = new \Illuminate\Console\Scheduling\Schedule('Asia/Shanghai');
  172. $kernel = new \App\Console\Kernel($this->app, new Dispatcher($this->app));
  173. $method = new \ReflectionMethod($kernel, 'schedule');
  174. $method->setAccessible(true);
  175. $method->invoke($kernel, $schedule);
  176. return $schedule->events();
  177. }
  178. public function test_console_scheduler_registers_the_automatic_payout_command_every_minute_with_overlap_protection(): void
  179. {
  180. $events = array_values(array_filter($this->scheduledEvents(), fn ($event) => str_contains($event->command ?? '', 'rewards:auto-grant')));
  181. $this->assertCount(1, $events);
  182. $this->assertSame('* * * * *', $events[0]->expression);
  183. $this->assertTrue($events[0]->withoutOverlapping);
  184. $this->assertSame(60, $events[0]->expiresAt);
  185. }
  186. public function test_console_scheduler_refreshes_reward_bases_and_wagers_after_bet_facts(): void
  187. {
  188. $commands = ['share:sync-facts --write', 'rewards:sync-bases', 'wagering:sync'];
  189. $scheduled = [];
  190. foreach ($this->scheduledEvents() as $event) {
  191. foreach ($commands as $command) {
  192. if (!str_ends_with($event->command ?? '', ' ' . $command)) continue;
  193. $this->assertSame('* * * * *', $event->expression);
  194. $this->assertTrue($event->withoutOverlapping);
  195. $scheduled[] = $command;
  196. }
  197. }
  198. $this->assertSame($commands, $scheduled);
  199. }
  200. public function test_migration_defaults_to_manual_and_both_settings_routes_keep_admin_and_existing_grant_permissions(): void
  201. {
  202. $id = $this->rebate(); $this->backflow();
  203. foreach (['rebate', 'backflow'] as $type) {
  204. $this->assertSame(['type' => $type, 'auto_enabled' => 0, 'grant_time' => '03:00', 'timezone' => 'Asia/Shanghai'], RewardGrantSettingsService::get($type));
  205. }
  206. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  207. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
  208. $this->assertSame(0, DB::table('balance_logs')->count());
  209. $router = new Router(new Dispatcher($this->app), $this->app);
  210. $this->app->instance('router', $router);
  211. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  212. foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) {
  213. foreach (['GET', 'POST'] as $method) {
  214. $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/grant-settings', $method));
  215. $this->assertContains('admin.jwt', $route->middleware());
  216. $this->assertContains('check.button.uri', $route->middleware());
  217. $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
  218. }
  219. }
  220. $this->migration()->down();
  221. $this->assertFalse(Schema::hasTable('reward_grant_settings'));
  222. }
  223. public function test_settings_api_persists_independent_times_and_disabling_preserves_the_previous_time_without_paying(): void
  224. {
  225. $this->rebate();
  226. $this->request('/admin/rebate/grant-settings', ['auto_enabled' => true, 'grant_time' => '14:30'], 'POST');
  227. $body = (new RewardGrantSettings())->saveRebate()->getData(true);
  228. $this->assertSame(0, $body['code']);
  229. $this->assertSame(1, $body['data']['auto_enabled']);
  230. $this->assertSame('14:30', $body['data']['grant_time']);
  231. $this->assertSame(0, RewardGrantSettingsService::get('backflow')['auto_enabled']);
  232. $this->request('/admin/backflow/grant-settings', ['auto_enabled' => 1, 'grant_time' => '20:15'], 'POST');
  233. $this->assertSame(0, (new RewardGrantSettings())->saveBackflow()->getData(true)['code']);
  234. $this->assertSame('20:15', RewardGrantSettingsService::get('backflow')['grant_time']);
  235. $this->request('/admin/rebate/grant-settings', ['auto_enabled' => 0], 'POST');
  236. $body = (new RewardGrantSettings())->saveRebate()->getData(true);
  237. $this->assertSame(0, $body['data']['auto_enabled']);
  238. $this->assertSame('14:30', $body['data']['grant_time']);
  239. $this->assertSame(0, DB::table('balance_logs')->count());
  240. $this->assertSame(0, (new RewardGrantSettings())->rebate()->getData(true)['code']);
  241. }
  242. #[DataProvider('invalidSettings')]
  243. public function test_invalid_settings_do_not_change_the_switch_or_time(array $values): void
  244. {
  245. $this->request('/admin/rebate/grant-settings', $values, 'POST');
  246. $this->assertSame(-3, (new RewardGrantSettings())->saveRebate()->getData(true)['code']);
  247. $this->assertSame(0, RewardGrantSettingsService::get('rebate')['auto_enabled']);
  248. $this->assertSame('03:00', RewardGrantSettingsService::get('rebate')['grant_time']);
  249. }
  250. public static function invalidSettings(): array
  251. {
  252. return [[[]], [['auto_enabled' => 2]], [['auto_enabled' => 'auto']], [['auto_enabled' => 1]],
  253. [['auto_enabled' => true]], [['auto_enabled' => 1, 'grant_time' => '']],
  254. [['auto_enabled' => 1, 'grant_time' => '24:00']], [['auto_enabled' => 1, 'grant_time' => '9:00']],
  255. [['auto_enabled' => 1, 'grant_time' => '12:60']], [['auto_enabled' => 0, 'grant_time' => ['03:00']]]];
  256. }
  257. public function test_exact_minute_pays_both_types_once_and_retries_do_not_duplicate_wallet_or_ledger(): void
  258. {
  259. $rebate = $this->rebate(); $backflow = $this->backflow();
  260. $this->rebate(['date' => '2026-10-25']); $this->backflow(['date' => '2026-10']);
  261. $this->rebate(['status' => 1]); $this->backflow(['status' => 1]);
  262. $this->enable('rebate'); $this->enable('backflow');
  263. Carbon::setTestNow(Carbon::parse('2026-10-25 14:29:59', 'Asia/Shanghai'));
  264. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  265. $this->assertSame(0, DB::table('balance_logs')->count());
  266. Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai'));
  267. $result = $this->runRewards();
  268. $this->assertSame(1, $result['rebate']['granted']); $this->assertSame(1, $result['backflow']['granted']);
  269. $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
  270. $this->assertEquals(4, DB::table('rebates')->where('id', $rebate)->value('amount'));
  271. $this->assertEquals(35, DB::table('backflow')->where('id', $backflow)->value('amount'));
  272. $this->assertSame('system:auto-grant', DB::table('rebates')->where('id', $rebate)->value('audited_by'));
  273. $this->assertSame(now()->timestamp, DB::table('backflow')->where('id', $backflow)->value('grant_time'));
  274. $this->assertSame(2, DB::table('balance_logs')->count());
  275. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  276. $this->assertSame(2, DB::table('balance_logs')->count());
  277. $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
  278. }
  279. public function test_scheduled_cutoffs_keep_new_york_days_and_shanghai_months_even_when_catching_up_later(): void
  280. {
  281. Carbon::setTestNow(Carbon::parse('2026-11-01 03:00:00', 'Asia/Shanghai'));
  282. $old = $this->rebate(['date' => '2026-10-30']);
  283. $notYetEndedAtSchedule = $this->rebate(['date' => '2026-10-31']);
  284. $lastMonth = $this->backflow(['date' => '2026-10']);
  285. $currentMonth = $this->backflow(['date' => '2026-11']);
  286. $this->enable('rebate', '03:00'); $this->enable('backflow', '03:00');
  287. // At 14:00 the New York day has changed, but this run still belongs to the 03:00 schedule.
  288. Carbon::setTestNow(Carbon::parse('2026-11-01 14:00:00', 'Asia/Shanghai'));
  289. $this->runRewards();
  290. $this->assertSame(1, DB::table('rebates')->where('id', $old)->value('status'));
  291. $this->assertSame(0, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status'));
  292. $this->assertSame(1, DB::table('backflow')->where('id', $lastMonth)->value('status'));
  293. $this->assertSame(0, DB::table('backflow')->where('id', $currentMonth)->value('status'));
  294. Carbon::setTestNow(Carbon::parse('2026-11-02 02:59:59', 'Asia/Shanghai'));
  295. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  296. Carbon::setTestNow(Carbon::parse('2026-11-02 03:00:00', 'Asia/Shanghai'));
  297. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  298. $this->assertSame(1, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status'));
  299. }
  300. public function test_disabled_switch_stops_auto_but_both_manual_controllers_still_work_and_stale_calls_cannot_pay_twice(): void
  301. {
  302. $id = $this->rebate(); $backflow = $this->backflow();
  303. $stale = Rebate::findOrFail($id);
  304. $this->enable('rebate');
  305. RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]);
  306. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  307. $this->request('/admin/rebate/submit', ['id' => $id], 'POST');
  308. $this->assertSame(0, (new RebateController())->store()->getData(true)['code']);
  309. $this->assertSame(-3, (new RebateController())->store()->getData(true)['code']);
  310. $this->assertSame('operator-one', DB::table('rebates')->where('id', $id)->value('audited_by'));
  311. $this->request('/admin/backflow/grant', ['id' => $backflow], 'POST');
  312. $this->assertSame(0, (new BackflowController())->grant()->getData(true)['code']);
  313. $this->assertSame(-3, (new BackflowController())->grant()->getData(true)['code']);
  314. $this->enable('rebate'); $this->enable('backflow');
  315. $this->runRewards();
  316. $this->assertFalse(RewardGrantService::grant('rebate', $id, 'auto', true));
  317. $this->assertSame(2, DB::table('balance_logs')->count());
  318. try { RebateService::BibiReturn($stale, '999999'); $this->fail('A stale row must not pay again'); }
  319. catch (\RuntimeException $e) { $this->assertSame(-3, $e->getCode()); }
  320. $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
  321. }
  322. public function test_payout_reads_fresh_effective_bet_and_zero_rewards_are_finalized_without_ledger_credits(): void
  323. {
  324. $id = $this->rebate(); $stale = Rebate::findOrFail($id);
  325. DB::table('rebates')->where('id', $id)->update(['effective_betting_amount' => 100]);
  326. RebateService::BibiReturn($stale, '800');
  327. $this->assertEquals(0.5, DB::table('rebates')->where('id', $id)->value('amount'));
  328. $this->rebate(['effective_betting_amount' => 0]);
  329. $below = $this->backflow(['recharge_amount' => 50, 'withdrawal_amount' => 0]);
  330. $this->enable('rebate'); $this->enable('backflow'); $this->runRewards();
  331. $this->assertSame(1, DB::table('backflow')->where('id', $below)->value('status'));
  332. $this->assertEquals(0, DB::table('backflow')->where('id', $below)->value('amount'));
  333. $this->assertSame(1, DB::table('balance_logs')->count());
  334. $this->assertEquals(20.5, DB::table('wallets')->value('available_balance'));
  335. }
  336. public function test_ledger_failure_rolls_back_wallet_and_reward_then_automatic_retry_credits_once(): void
  337. {
  338. $id = $this->rebate(); $this->enable('rebate');
  339. DB::statement("CREATE TRIGGER reject_reward_ledger BEFORE INSERT ON bot_balance_logs BEGIN SELECT RAISE(ABORT, 'ledger unavailable'); END");
  340. $this->assertSame(1, $this->runRewards()['rebate']['failed']);
  341. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  342. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
  343. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('grant_time'));
  344. $this->assertSame(0, DB::table('balance_logs')->count());
  345. DB::statement('DROP TRIGGER reject_reward_ledger');
  346. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  347. $this->assertEquals(24, DB::table('wallets')->value('available_balance'));
  348. $this->assertSame(1, DB::table('balance_logs')->count());
  349. }
  350. public function test_status_save_failure_rolls_back_backflow_wallet_and_ledger_and_missing_wallet_does_not_stop_other_rows(): void
  351. {
  352. $id = $this->backflow(); $this->enable('backflow');
  353. DB::statement("CREATE TRIGGER reject_backflow_status BEFORE UPDATE ON bot_backflow BEGIN SELECT RAISE(ABORT, 'reward unavailable'); END");
  354. $this->assertSame(1, $this->runRewards()['backflow']['failed']);
  355. $this->assertSame(0, DB::table('backflow')->where('id', $id)->value('status'));
  356. $this->assertSame(0, DB::table('balance_logs')->count());
  357. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  358. DB::statement('DROP TRIGGER reject_backflow_status');
  359. $bad = $this->rebate(['member_id' => 'no-wallet']); $good = $this->rebate(); $this->enable('rebate');
  360. $result = $this->runRewards();
  361. $this->assertSame(1, $result['rebate']['failed']); $this->assertSame(1, $result['rebate']['granted']);
  362. $this->assertSame(0, DB::table('rebates')->where('id', $bad)->value('status'));
  363. $this->assertSame(1, DB::table('rebates')->where('id', $good)->value('status'));
  364. $this->assertSame(1, $result['backflow']['granted']);
  365. }
  366. public function test_switch_and_time_are_rechecked_between_records_and_before_retrying(): void
  367. {
  368. $this->rebate(); $second = $this->rebate(); $this->enable('rebate');
  369. $changed = false;
  370. $this->app['db']->connection()->listen(function ($event) use (&$changed) {
  371. if (!$changed && str_starts_with(strtolower($event->sql), 'update "bot_rebates"')) {
  372. $changed = true;
  373. RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]);
  374. }
  375. });
  376. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  377. $this->assertSame(0, DB::table('rebates')->where('id', $second)->value('status'));
  378. $this->assertSame(1, DB::table('balance_logs')->count());
  379. $this->enable('rebate', '15:00');
  380. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  381. Carbon::setTestNow(Carbon::parse('2026-10-25 15:00:00', 'Asia/Shanghai'));
  382. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  383. }
  384. public function test_chunked_processing_does_not_skip_rows_and_command_reports_failure_without_forcing_disabled_rewards(): void
  385. {
  386. for ($i = 0; $i < 105; ++$i) $this->rebate(['effective_betting_amount' => 0]);
  387. $command = new AutoGrantRewards(); $command->setLaravel($this->app);
  388. $tester = new CommandTester($command);
  389. $tester->execute([]);
  390. $this->assertSame(0, $tester->getStatusCode());
  391. $this->assertSame(105, DB::table('rebates')->where('status', 0)->count());
  392. $this->enable('rebate'); $tester->execute([]);
  393. $this->assertSame(0, $tester->getStatusCode());
  394. $this->assertStringContainsString('"granted":105', $tester->getDisplay());
  395. $this->assertSame(105, DB::table('rebates')->where('status', 1)->count());
  396. $this->rebate(['member_id' => 'missing']); $tester->execute([]);
  397. $this->assertSame(1, $tester->getStatusCode());
  398. }
  399. public function test_rules_api_lists_categories_and_levels_replaces_one_category_and_rejects_bad_tiers(): void
  400. {
  401. $body = (new RewardRules())->rebate()->getData(true);
  402. $this->assertSame(0, $body['code']);
  403. $this->assertContains(['value' => 'third:1', 'label' => '真人'], $body['data']['categories']);
  404. $this->assertSame([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']], $body['data']['levels']);
  405. $this->assertSame([], $body['data']['rules']['third:2']);
  406. $this->request('/admin/rebate/rules', ['category' => 'third:2', 'rules' => [
  407. ['level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
  408. ['level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
  409. ]], 'POST');
  410. $body = (new RewardRules())->saveRebate()->getData(true);
  411. $this->assertSame(0, $body['code']);
  412. $this->assertSame(['0.00', '1000.00'], array_column($body['data']['rules']['third:2'], 'min_effective_amount'));
  413. $this->assertSame('3.00', $body['data']['rules']['third:2'][1]['turnover_multiple']);
  414. $this->assertCount(1, $body['data']['rules']['self:lottery']);
  415. foreach ([['category' => 'third:9', 'rules' => []],
  416. ['category' => 'third:2', 'rules' => [['level' => 7, 'min_effective_amount' => 0, 'percentage' => 1, 'turnover_multiple' => 0]]],
  417. ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => '1e3', 'percentage' => 1, 'turnover_multiple' => 0]]],
  418. ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 0, 'percentage' => 101, 'turnover_multiple' => 0]]],
  419. ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 5, 'percentage' => 1, 'turnover_multiple' => 0],
  420. ['level' => 1, 'min_effective_amount' => 5, 'percentage' => 2, 'turnover_multiple' => 0]]]] as $bad) {
  421. $this->request('/admin/rebate/rules', $bad, 'POST');
  422. $this->assertSame(-3, (new RewardRules())->saveRebate()->getData(true)['code']);
  423. }
  424. $this->assertSame(2, DB::table('rebate_rules')->where('category', 'third:2')->count());
  425. $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => [
  426. ['min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2]]], 'POST');
  427. $body = (new RewardRules())->saveBackflow()->getData(true);
  428. $this->assertSame([['id' => 2, 'category' => 'third:1', 'min_loss_amount' => '2000.00', 'reward_amount' => '60.00',
  429. 'turnover_multiple' => '2.00']], $body['data']['rules']['third:1']);
  430. $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => []], 'POST');
  431. $this->assertSame(0, (new RewardRules())->saveBackflow()->getData(true)['code']);
  432. $this->assertSame(0, DB::table('backflow_rules')->where('category', 'third:1')->count());
  433. $router = new Router(new Dispatcher($this->app), $this->app);
  434. $this->app->instance('router', $router);
  435. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  436. foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) {
  437. foreach (['GET', 'POST'] as $method) {
  438. $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/rules', $method));
  439. $this->assertContains('check.button.uri', $route->middleware());
  440. $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
  441. }
  442. }
  443. }
  444. public function test_rebate_sums_every_category_by_new_york_day_and_uses_the_members_level_tier_and_turnover(): void
  445. {
  446. $this->user('2001', 2);
  447. DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
  448. DB::table('rebate_rules')->insert([
  449. ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
  450. ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
  451. ['category' => 'third:2', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '2', 'turnover_multiple' => 0],
  452. ['category' => 'self:sport', 'level' => 2, 'min_effective_amount' => 500, 'percentage' => '1', 'turnover_multiple' => 0],
  453. ]);
  454. // New York 2026-10-23 is Shanghai 2026-10-23 12:00 to 2026-10-24 11:59:59.
  455. $this->fact('2001', 'third:2', '2026-10-23 20:00:00', '1000', '-1000');
  456. $this->fact('2001', 'third:2', '2026-10-24 10:00:00', '500', '500');
  457. $this->fact('2001', 'self:sport', '2026-10-24 11:00:00', '300', '-300');
  458. $this->fact('2001', 'third:2', '2026-10-24 11:30:00', '9999', '0', ['voided' => 1]);
  459. $this->fact('2001', 'third:2', '2026-10-24 13:00:00', '7777', '-7777');
  460. $this->assertSame(['rebates' => 2, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run());
  461. $row = DB::table('rebates')->where('member_id', '2001')->where('date', '2026-10-23')->first();
  462. $this->assertEquals(1800, $row->betting_amount);
  463. $this->assertEquals(1800, $row->effective_betting_amount);
  464. $this->assertEquals(15, $row->amount);
  465. $this->assertEquals(45, $row->need_flow);
  466. $this->assertSame(['self:sport', 'third:2'], array_column(json_decode($row->details, true), 'category'));
  467. $this->assertTrue(RewardGrantService::grant('rebate', (int) $row->id, 'operator-one'));
  468. $this->assertEquals(15, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
  469. $bonus = DB::table('wallet_bonus')->first();
  470. $this->assertSame('2001', $bonus->member_id);
  471. $this->assertEquals(45, $bonus->need_flow);
  472. $this->assertEquals(15, $bonus->amount);
  473. $this->assertSame((int) $row->id, (int) $bonus->related_id);
  474. $this->assertStringContainsString('电子:1500.00×1.0000%', DB::table('balance_logs')->value('remark'));
  475. // A paid day is never rewritten by later facts.
  476. $this->fact('2001', 'third:2', '2026-10-24 09:00:00', '100', '-100');
  477. (new RewardBaseSyncService())->run();
  478. $this->assertEquals(1800, DB::table('rebates')->where('id', $row->id)->value('effective_betting_amount'));
  479. $this->assertSame(1, DB::table('wallet_bonus')->count());
  480. }
  481. public function test_backflow_pays_fixed_rewards_for_each_categorys_monthly_net_loss(): void
  482. {
  483. $this->user('2001');
  484. DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
  485. DB::table('backflow_rules')->insert([
  486. ['category' => 'third:1', 'min_loss_amount' => 1000, 'reward_amount' => 20, 'turnover_multiple' => 0],
  487. ['category' => 'third:1', 'min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2],
  488. ['category' => 'third:2', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0],
  489. ['category' => 'self:sport', 'min_loss_amount' => 100, 'reward_amount' => 5, 'turnover_multiple' => 0],
  490. ]);
  491. $this->fact('2001', 'third:1', '2026-09-03 10:00:00', '2000', '-2000');
  492. $this->fact('2001', 'third:1', '2026-09-28 10:00:00', '500', '-500');
  493. $this->fact('2001', 'third:2', '2026-09-05 10:00:00', '100', '100');
  494. $this->fact('2001', 'self:sport', '2026-09-30 23:00:00', '50', '-50');
  495. $this->fact('2001', 'self:sport', '2026-10-01 00:30:00', '900', '-900');
  496. (new RewardBaseSyncService())->run();
  497. $row = DB::table('backflow')->where('member_id', '2001')->where('date', '2026-09')->first();
  498. $this->assertEquals(2550, $row->loss_amount);
  499. $this->assertEquals(60, $row->amount);
  500. $this->assertTrue(RewardGrantService::grant('backflow', (int) $row->id, 'operator-one'));
  501. $this->assertEquals(60, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
  502. $this->assertEquals(120, DB::table('wallet_bonus')->value('need_flow'));
  503. $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow'));
  504. }
  505. public function test_backflow_can_be_granted_again_for_the_remainder_until_the_month_is_closed(): void
  506. {
  507. // now = 2026-10-25 14:30 上海;当月 2026-10。钱包初始 20。
  508. DB::table('backflow_rules')->insert([
  509. ['category' => 'self:lottery', 'min_loss_amount' => 10, 'reward_amount' => 8, 'turnover_multiple' => 1],
  510. ['category' => 'self:lottery', 'min_loss_amount' => 300, 'reward_amount' => 50, 'turnover_multiple' => 1],
  511. ]);
  512. $id = $this->backflow(['date' => '2026-10', 'recharge_amount' => '50.00', 'withdrawal_amount' => '0']); // 亏 50
  513. $this->enable('backflow');
  514. $this->assertSame(0, $this->runRewards()['backflow']['granted'], '自动发放不发当月');
  515. // 第一次手动发放:当月亏 50 → 8,记为部分发放
  516. $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-one'));
  517. $row = DB::table('backflow')->where('id', $id)->first();
  518. $this->assertSame([2, '8.00', '8.00'], [(int) $row->status, bcadd($row->amount, 0, 2), bcadd($row->granted_amount, 0, 2)]);
  519. $this->assertEquals(28, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
  520. $this->assertSame(['amount' => '8.00', 'granted_amount' => '8.00', 'pending_amount' => '0.00', 'can_grant' => false, 'status_text' => '部分发放'],
  521. BackflowService::grantFields((array) $row));
  522. // 没有差额时当月不能再发
  523. try { RewardGrantService::grant('backflow', $id, 'operator-one'); $this->fail('expected exception'); }
  524. catch (\RuntimeException $e) { $this->assertSame('暂无可发放金额', $e->getMessage()); }
  525. // 继续亏到 350:同步后应发 50,已发 8,待发 42
  526. $this->fact('1001', 'self:lottery', '2026-10-20 12:00:00', '300', '-300');
  527. (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20');
  528. $fields = BackflowService::grantFields((array) DB::table('backflow')->where('id', $id)->first());
  529. $this->assertSame(['50.00', '8.00', '42.00', true], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant']]);
  530. $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-two'));
  531. $row = DB::table('backflow')->where('id', $id)->first();
  532. $this->assertSame([2, '50.00', '50.00'], [(int) $row->status, bcadd($row->granted_amount, 0, 2), bcadd($row->granted_need_flow, 0, 2)]);
  533. $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
  534. $this->assertSame(['8.0000000000', '42.0000000000'], DB::table('balance_logs')->where('change_type', '回水')->orderBy('id')->pluck('amount')
  535. ->map(fn ($v) => bcadd($v, 0, 10))->all());
  536. $this->assertStringContainsString('本次发放:42.00;累计发放:50.00', DB::table('balance_logs')->where('change_type', '回水')->orderByDesc('id')->value('remark'));
  537. // 下个月自动发放:无差额,只结清为已发放,不重复入账
  538. Carbon::setTestNow(Carbon::parse('2026-11-01 14:30:00', 'Asia/Shanghai'));
  539. $this->assertSame(1, $this->runRewards()['backflow']['granted']);
  540. $this->assertSame(1, (int) DB::table('backflow')->where('id', $id)->value('status'));
  541. $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
  542. $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count());
  543. // 笔笔返仍须等当天结束
  544. $rebate = $this->rebate(['date' => Carbon::now('America/New_York')->format('Y-m-d')]);
  545. $this->expectExceptionMessage('未到发放时间');
  546. RewardGrantService::grant('rebate', $rebate, 'operator-one');
  547. }
  548. public function test_granted_amount_migration_backfills_paid_rows(): void
  549. {
  550. $paid = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-08', 'status' => 1, 'amount' => '35', 'need_flow' => '70', 'granted_amount' => 0]);
  551. $open = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-07', 'status' => 0, 'amount' => '35', 'granted_amount' => 0]);
  552. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
  553. $this->assertEquals([35, 70], array_values((array) DB::table('backflow')->where('id', $paid)->first(['granted_amount', 'granted_need_flow'])));
  554. $this->assertEquals(0, DB::table('backflow')->where('id', $open)->value('granted_amount'));
  555. }
  556. public function test_sync_cursor_only_revisits_changed_facts_and_backfill_range_creates_missing_rows(): void
  557. {
  558. $this->fact('3001', 'third:1', '2026-10-20 13:00:00', '100', '-100', ['updated_at' => '2026-10-20 13:00:00']);
  559. $this->assertSame(0, (new RewardBaseSyncService())->run()['rebates']);
  560. $this->assertSame(['rebates' => 1, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20'));
  561. $this->assertEquals(100, DB::table('rebates')->where('member_id', '3001')->value('effective_betting_amount'));
  562. $this->assertNotNull(DB::table('share_system_state')->where('key', 'reward_bases_cursor')->value('value'));
  563. $this->expectException(\InvalidArgumentException::class);
  564. (new RewardBaseSyncService())->run('2026-10-20', null);
  565. }
  566. public function test_rules_migration_removes_the_three_global_settings_and_rollback_restores_them(): void
  567. {
  568. $this->assertSame(['huishui_restriction'], DB::table('config')->pluck('field')->all());
  569. $this->assertTrue(Schema::hasColumns('rebates', ['details', 'need_flow']));
  570. $this->rulesMigration()->down();
  571. $this->assertEqualsCanonicalizing(['rebate', 'huishui_restriction', 'huishui_percentage'], DB::table('config')->pluck('field')->all());
  572. $this->assertSame('0.005', DB::table('config')->where('field', 'rebate')->value('val'));
  573. $this->assertFalse(Schema::hasTable('rebate_rules'));
  574. $this->assertFalse(Schema::hasColumn('backflow', 'loss_amount'));
  575. }
  576. }