AdminOrderRefundTest.php 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\JisuGameOrder;
  4. use App\Http\Controllers\admin\LhcOrder;
  5. use App\Http\Controllers\admin\Order;
  6. use App\Http\Controllers\admin\SportGameOrder;
  7. use Illuminate\Config\Repository;
  8. use Illuminate\Database\Capsule\Manager;
  9. use Illuminate\Database\Eloquent\Model;
  10. use Illuminate\Database\Schema\Blueprint;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Http\Request;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Facade;
  15. use Illuminate\Translation\ArrayLoader;
  16. use Illuminate\Translation\Translator;
  17. use Illuminate\Validation\Factory;
  18. use PHPUnit\Framework\Attributes\DataProvider;
  19. use PHPUnit\Framework\TestCase;
  20. class AdminOrderRefundTest extends TestCase
  21. {
  22. private Application $app;
  23. private const CONTROLLERS = ['sport' => Order::class, 'game' => SportGameOrder::class, 'lottery' => LhcOrder::class, 'jisu' => JisuGameOrder::class];
  24. private const TABLES = ['sport' => 'sport_order', 'game' => 'sport_game_order', 'lottery' => 'lhc_order', 'jisu' => 'jisu_game_order'];
  25. protected function setUp(): void
  26. {
  27. Facade::clearResolvedInstances(); Model::clearBootedModels(); Model::unsetEventDispatcher();
  28. $this->app = new Application(dirname(__DIR__, 2));
  29. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
  30. $this->app->instance('log', new \Psr\Log\NullLogger());
  31. $translator = new Translator(new ArrayLoader(), 'zh');
  32. $this->app->instance('translator', $translator);
  33. $this->app->instance('validator', new Factory($translator, $this->app));
  34. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  35. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  36. Facade::setFacadeApplication($this->app);
  37. $db = new Manager($this->app);
  38. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  39. $db->setAsGlobal(); $db->bootEloquent();
  40. $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
  41. $db->schema()->create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 2); $t->timestamps(); });
  42. $db->schema()->create('balance_logs', function (Blueprint $t) {
  43. $t->id(); $t->string('member_id'); $t->decimal('amount', 24, 2); $t->decimal('before_balance', 24, 2); $t->decimal('after_balance', 24, 2);
  44. $t->string('change_type'); $t->string('remark'); $t->integer('related_id')->nullable(); $t->timestamps();
  45. });
  46. foreach (self::TABLES as $kind => $table) $db->schema()->create($table, function (Blueprint $t) use ($kind) {
  47. $t->id(); $t->string($kind === 'sport' ? 'user_id' : 'member_id'); $t->decimal('amount', 24, 2);
  48. if ($kind === 'lottery') { $t->string('ordernum'); $t->decimal('total_amount', 24, 2)->nullable(); $t->integer('lottery_status')->default(0); }
  49. else $t->integer('status')->default($kind === 'sport' ? 1 : 0);
  50. $t->integer('type')->default(1);
  51. if ($kind === 'sport') { $t->integer('pay_status')->default(1); $t->integer('return_status')->default(0); $t->integer('settlement_status')->default(0); $t->integer('return_operation_time')->nullable(); $t->integer('create_time')->nullable(); $t->integer('update_time')->nullable(); }
  52. else $t->timestamps();
  53. });
  54. $db->schema()->create('lhc_order_item', function (Blueprint $t) { $t->id(); $t->string('ordernum'); $t->timestamps(); });
  55. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_120000_add_promotion_coupons.php')->up();
  56. DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50']);
  57. }
  58. protected function tearDown(): void
  59. {
  60. DB::disconnect(); Model::clearBootedModels(); Facade::clearResolvedInstances(); parent::tearDown();
  61. }
  62. public static function kinds(): array { return array_map(fn ($kind) => [$kind], array_keys(self::TABLES)); }
  63. private function seed(string $kind, int $id = 1, bool $coupon = true, bool $missingTotal = false): string
  64. {
  65. $coupon = $coupon && $kind !== 'jisu';
  66. $row = ['id' => $id, $kind === 'sport' ? 'user_id' : 'member_id' => '1001', 'amount' => '100'];
  67. if ($kind === 'lottery') {
  68. $row += ['ordernum' => 'slip-' . $id, 'total_amount' => $missingTotal ? null : '300'];
  69. foreach ([1, 2, 3] as $number) DB::table('lhc_order_item')->insert(['ordernum' => $row['ordernum']]);
  70. }
  71. if ($coupon) $row += ['coupon_id' => $id, 'coupon_discount' => '50'];
  72. DB::table(self::TABLES[$kind])->insert($row);
  73. if ($coupon) {
  74. DB::table('promotion_coupons')->insert(['id' => $id, 'activity_id' => 1, 'reward_id' => $id, 'user_id' => 1, 'member_id' => '1001',
  75. 'role' => 'inviter', 'inviter_user_id' => 1, 'seq' => 1, 'min_amount' => '100', 'discount' => '50', 'categories' => '["self:sport","self:lottery"]',
  76. 'valid_from' => '2000-01-01 00:00:00', 'valid_until' => '2099-12-31 23:59:59', 'status' => 'used']);
  77. DB::table('promotion_coupon_uses')->insert(['coupon_id' => $id, 'source' => self::TABLES[$kind], 'order_id' => $id, 'discount' => '50']);
  78. }
  79. return $kind === 'lottery' ? ($coupon ? '250.00' : '300.00') : ($coupon ? '50.00' : '100.00');
  80. }
  81. private function refund(string $kind, array $ids = [1]): array
  82. {
  83. $this->app->instance('request', Request::create('/admin/refund', 'POST', ['id' => $ids]));
  84. return (new (self::CONTROLLERS[$kind])())->refund()->getData(true);
  85. }
  86. private function snapshot(): array
  87. {
  88. $result = [];
  89. foreach (['wallets', 'balance_logs', 'promotion_coupons', 'promotion_coupon_uses', ...array_values(self::TABLES)] as $table) $result[$table] = DB::table($table)->orderBy('id')->get()->toJson();
  90. return $result;
  91. }
  92. #[DataProvider('kinds')]
  93. public function test_refund_credits_paid_amount_returns_coupon_and_retries_without_duplicates(string $kind): void
  94. {
  95. $refund = $this->seed($kind);
  96. $this->assertSame(0, $this->refund($kind)['code']);
  97. $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
  98. $log = DB::table('balance_logs')->first();
  99. $this->assertSame($refund, bcadd((string)$log->amount, '0', 2));
  100. $this->assertSame(1, (int)$log->related_id);
  101. $this->assertSame('50.00', bcadd((string)$log->before_balance, '0', 2));
  102. $this->assertSame(bcadd('50', $refund, 2), bcadd((string)$log->after_balance, '0', 2));
  103. if ($kind !== 'jisu') $this->assertSame('unused', DB::table('promotion_coupons')->value('status'));
  104. $before = $this->snapshot();
  105. $this->assertSame(0, $this->refund($kind)['code']);
  106. $this->assertSame($before, $this->snapshot());
  107. }
  108. #[DataProvider('kinds')]
  109. public function test_refund_without_coupon_credits_the_complete_order(string $kind): void
  110. {
  111. $refund = $this->seed($kind, coupon: false);
  112. $this->assertSame(0, $this->refund($kind)['code']);
  113. $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
  114. }
  115. #[DataProvider('kinds')]
  116. public function test_missing_wallet_rolls_back_terminal_state_and_coupon(string $kind): void
  117. {
  118. $this->seed($kind); DB::table('wallets')->delete(); $before = $this->snapshot();
  119. $this->assertSame(-3, $this->refund($kind)['code']);
  120. $this->assertSame($before, $this->snapshot());
  121. }
  122. #[DataProvider('kinds')]
  123. public function test_later_ledger_failure_rolls_back_the_entire_refund_batch(string $kind): void
  124. {
  125. $this->seed($kind); $this->seed($kind, 2); $before = $this->snapshot();
  126. DB::statement("CREATE TRIGGER reject_admin_refund BEFORE INSERT ON bot_balance_logs WHEN NEW.related_id = 2 BEGIN SELECT RAISE(ABORT, 'fixture ledger failure'); END");
  127. $this->assertSame(-3, $this->refund($kind, [1, 2])['code']);
  128. $this->assertSame($before, $this->snapshot());
  129. }
  130. public function test_legacy_lottery_missing_total_is_derived_from_its_numbers(): void
  131. {
  132. $this->seed('lottery', coupon: false, missingTotal: true);
  133. $this->assertSame(0, $this->refund('lottery')['code']);
  134. $this->assertSame('350.00', bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
  135. }
  136. }