SettleLedgerTest.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\api\Settle;
  4. use App\Models\BalanceLog;
  5. use App\Models\Bet;
  6. use App\Models\PcIssue;
  7. use Illuminate\Cache\ArrayStore;
  8. use Illuminate\Cache\Repository as CacheRepository;
  9. use Illuminate\Config\Repository as ConfigRepository;
  10. use Illuminate\Database\Capsule\Manager;
  11. use Illuminate\Database\Schema\Blueprint;
  12. use Illuminate\Foundation\Application;
  13. use Illuminate\Http\JsonResponse;
  14. use Illuminate\Http\Request;
  15. use Illuminate\Support\Facades\DB;
  16. use Illuminate\Support\Facades\Facade;
  17. use Illuminate\Translation\ArrayLoader;
  18. use Illuminate\Translation\Translator;
  19. use PHPUnit\Framework\TestCase;
  20. use Psr\Log\NullLogger;
  21. /** Uses real settlement code with an in-memory database and cache, without application providers. */
  22. class SettleLedgerTest extends TestCase
  23. {
  24. private Application $app;
  25. protected function setUp(): void
  26. {
  27. Facade::clearResolvedInstances();
  28. $this->app = new Application(dirname(__DIR__, 2));
  29. $this->app->instance('config', new ConfigRepository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  30. $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
  31. Facade::setFacadeApplication($this->app);
  32. $db = new Manager($this->app);
  33. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  34. $db->setAsGlobal();
  35. $db->bootEloquent();
  36. $this->app->instance('db', $db->getDatabaseManager());
  37. $this->app->instance('cache', new CacheRepository(new ArrayStore()));
  38. $this->app->instance('log', new NullLogger());
  39. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  40. public function json($data, $status = 200, $headers = [], $options = 0): JsonResponse
  41. {
  42. return new JsonResponse($data, $status, $headers, $options);
  43. }
  44. });
  45. foreach (['pc_issues', 'issues'] as $table) {
  46. $db->schema()->create($table, function (Blueprint $t) {
  47. $t->id();
  48. $t->string('issue_no');
  49. $t->string('winning_numbers');
  50. $t->integer('status');
  51. $t->dateTime('end_time');
  52. $t->timestamps();
  53. });
  54. }
  55. $db->schema()->create('gameplay_rules', function (Blueprint $t) {
  56. $t->id();
  57. $t->string('keywords');
  58. $t->decimal('odds', 10, 2);
  59. $t->timestamps();
  60. });
  61. $db->schema()->create('bets', function (Blueprint $t) {
  62. $t->id();
  63. $t->string('issue_no');
  64. $t->string('keywords');
  65. $t->decimal('amount', 20, 2);
  66. $t->decimal('odds', 10, 2);
  67. $t->integer('status');
  68. $t->decimal('profit', 20, 2);
  69. $t->integer('issue_id');
  70. $t->string('member_id');
  71. $t->string('user_id');
  72. $t->timestamps();
  73. });
  74. $db->schema()->create('wallets', function (Blueprint $t) {
  75. $t->id();
  76. $t->string('member_id')->unique();
  77. $t->decimal('available_balance', 20, 2);
  78. $t->timestamps();
  79. });
  80. $db->schema()->create('balance_logs', function (Blueprint $t) {
  81. $t->id();
  82. $t->string('member_id');
  83. $t->integer('type')->default(1);
  84. $t->decimal('amount', 20, 2);
  85. $t->decimal('before_balance', 20, 2);
  86. $t->decimal('after_balance', 20, 2);
  87. $t->string('change_type');
  88. $t->integer('related_id');
  89. $t->string('remark');
  90. $t->timestamps();
  91. });
  92. }
  93. protected function tearDown(): void
  94. {
  95. DB::disconnect();
  96. Facade::clearResolvedInstances();
  97. parent::tearDown();
  98. }
  99. public static function settlementGames(): array
  100. {
  101. return [
  102. '极速28' => ['pc28', 'pc_issues', '极速28投注'],
  103. '加拿大28' => ['ca28', 'issues', '加拿大28投注'],
  104. ];
  105. }
  106. /** @dataProvider settlementGames */
  107. public function test_settlement_ledger_matches_wallet_and_accumulated_consumption(string $game, string $issueTable, string $betType): void
  108. {
  109. DB::table($issueTable)->insert([
  110. 'issue_no' => '100001', 'winning_numbers' => '6,6,6',
  111. 'status' => PcIssue::STATUS_DRAW, 'end_time' => '2026-10-13 12:00:00',
  112. ]);
  113. DB::table('gameplay_rules')->insert(['keywords' => '大', 'odds' => '2.00']);
  114. DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '1000.50']);
  115. // An existing real debit must accumulate with the injected debit rather than cancel it.
  116. DB::table('balance_logs')->insert([
  117. 'member_id' => '1001', 'amount' => '-100.25',
  118. 'before_balance' => '1100.75', 'after_balance' => '1000.50',
  119. 'change_type' => $betType, 'related_id' => 999, 'remark' => 'existing bet',
  120. ]);
  121. $this->assertSame('100.25', BalanceLog::getTotalConsume('1001'));
  122. $request = Request::create('/api/pcissue/verify', 'POST', [
  123. 'issue_no' => '100001', 'member_id' => '1001', 'keywords' => '大',
  124. 'amount' => '100.25', 'fake_time' => '2026-10-13 11:58:00', 'game' => $game,
  125. ]);
  126. $this->app->instance('request', $request);
  127. $result = (new Settle())->inject($request)->getData(true);
  128. $this->assertSame(200, $result['code'], $result['msg'] ?? '');
  129. $this->assertSame('200.50', BalanceLog::getTotalConsume('1001'));
  130. $walletBalance = DB::table('wallets')->where('member_id', '1001')->value('available_balance');
  131. $this->assertSame('1100.75', bcadd((string)$walletBalance, '0', 2));
  132. $this->assertSame('1100.75', bcadd((string)$result['balance_after'], '0', 2));
  133. $logs = DB::table('balance_logs')->where('related_id', $result['order_id'])->orderBy('id')->get();
  134. $this->assertCount(2, $logs);
  135. $this->assertSame($betType, $logs[0]->change_type);
  136. $this->assertSame('-100.25', bcadd((string)$logs[0]->amount, '0', 2));
  137. $this->assertSame('900.25', bcadd((string)$logs[0]->after_balance, '0', 2));
  138. $this->assertSame('中奖', $logs[1]->change_type);
  139. $this->assertSame('200.50', bcadd((string)$logs[1]->amount, '0', 2));
  140. $this->assertSame('1100.75', bcadd((string)$logs[1]->after_balance, '0', 2));
  141. $this->assertSame(0, bccomp((string)$logs[0]->after_balance, (string)$logs[1]->before_balance, 2));
  142. foreach ($logs as $log) {
  143. $this->assertSame(0, bccomp((string)$log->amount, bcsub((string)$log->after_balance, (string)$log->before_balance, 2), 2));
  144. }
  145. $ledgerChange = bcadd((string)$logs[0]->amount, (string)$logs[1]->amount, 2);
  146. $this->assertSame($ledgerChange, bcsub((string)$walletBalance, '1000.50', 2));
  147. $this->assertSame(Bet::STATUS_SETTLED, (int)DB::table('bets')->where('id', $result['order_id'])->value('status'));
  148. }
  149. }