Order::class, 'game' => SportGameOrder::class, 'lottery' => LhcOrder::class, 'jisu' => JisuGameOrder::class]; private const TABLES = ['sport' => 'sport_order', 'game' => 'sport_game_order', 'lottery' => 'lhc_order', 'jisu' => 'jisu_game_order']; protected function setUp(): void { Facade::clearResolvedInstances(); Model::clearBootedModels(); Model::unsetEventDispatcher(); $this->app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']])); $this->app->instance('log', new \Psr\Log\NullLogger()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $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(); }); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $db->schema()->create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 2); $t->timestamps(); }); $db->schema()->create('balance_logs', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('amount', 24, 2); $t->decimal('before_balance', 24, 2); $t->decimal('after_balance', 24, 2); $t->string('change_type'); $t->string('remark'); $t->integer('related_id')->nullable(); $t->timestamps(); }); foreach (self::TABLES as $kind => $table) $db->schema()->create($table, function (Blueprint $t) use ($kind) { $t->id(); $t->string($kind === 'sport' ? 'user_id' : 'member_id'); $t->decimal('amount', 24, 2); if ($kind === 'lottery') { $t->string('ordernum'); $t->decimal('total_amount', 24, 2)->nullable(); $t->integer('lottery_status')->default(0); } else $t->integer('status')->default($kind === 'sport' ? 1 : 0); $t->integer('type')->default(1); 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(); } else $t->timestamps(); }); $db->schema()->create('lhc_order_item', function (Blueprint $t) { $t->id(); $t->string('ordernum'); $t->timestamps(); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_120000_add_promotion_coupons.php')->up(); DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50']); } protected function tearDown(): void { DB::disconnect(); Model::clearBootedModels(); Facade::clearResolvedInstances(); parent::tearDown(); } public static function kinds(): array { return array_map(fn ($kind) => [$kind], array_keys(self::TABLES)); } private function seed(string $kind, int $id = 1, bool $coupon = true, bool $missingTotal = false): string { $coupon = $coupon && $kind !== 'jisu'; $row = ['id' => $id, $kind === 'sport' ? 'user_id' : 'member_id' => '1001', 'amount' => '100']; if ($kind === 'lottery') { $row += ['ordernum' => 'slip-' . $id, 'total_amount' => $missingTotal ? null : '300']; foreach ([1, 2, 3] as $number) DB::table('lhc_order_item')->insert(['ordernum' => $row['ordernum']]); } if ($coupon) $row += ['coupon_id' => $id, 'coupon_discount' => '50']; DB::table(self::TABLES[$kind])->insert($row); if ($coupon) { DB::table('promotion_coupons')->insert(['id' => $id, 'activity_id' => 1, 'reward_id' => $id, 'user_id' => 1, 'member_id' => '1001', 'role' => 'inviter', 'inviter_user_id' => 1, 'seq' => 1, 'min_amount' => '100', 'discount' => '50', 'categories' => '["self:sport","self:lottery"]', 'valid_from' => '2000-01-01 00:00:00', 'valid_until' => '2099-12-31 23:59:59', 'status' => 'used']); DB::table('promotion_coupon_uses')->insert(['coupon_id' => $id, 'source' => self::TABLES[$kind], 'order_id' => $id, 'discount' => '50']); } return $kind === 'lottery' ? ($coupon ? '250.00' : '300.00') : ($coupon ? '50.00' : '100.00'); } private function refund(string $kind, array $ids = [1]): array { $this->app->instance('request', Request::create('/admin/refund', 'POST', ['id' => $ids])); return (new (self::CONTROLLERS[$kind])())->refund()->getData(true); } private function snapshot(): array { $result = []; foreach (['wallets', 'balance_logs', 'promotion_coupons', 'promotion_coupon_uses', ...array_values(self::TABLES)] as $table) $result[$table] = DB::table($table)->orderBy('id')->get()->toJson(); return $result; } #[DataProvider('kinds')] public function test_refund_credits_paid_amount_returns_coupon_and_retries_without_duplicates(string $kind): void { $refund = $this->seed($kind); $this->assertSame(0, $this->refund($kind)['code']); $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2)); $log = DB::table('balance_logs')->first(); $this->assertSame($refund, bcadd((string)$log->amount, '0', 2)); $this->assertSame(1, (int)$log->related_id); $this->assertSame('50.00', bcadd((string)$log->before_balance, '0', 2)); $this->assertSame(bcadd('50', $refund, 2), bcadd((string)$log->after_balance, '0', 2)); if ($kind !== 'jisu') $this->assertSame('unused', DB::table('promotion_coupons')->value('status')); $before = $this->snapshot(); $this->assertSame(0, $this->refund($kind)['code']); $this->assertSame($before, $this->snapshot()); } #[DataProvider('kinds')] public function test_refund_without_coupon_credits_the_complete_order(string $kind): void { $refund = $this->seed($kind, coupon: false); $this->assertSame(0, $this->refund($kind)['code']); $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2)); } #[DataProvider('kinds')] public function test_missing_wallet_rolls_back_terminal_state_and_coupon(string $kind): void { $this->seed($kind); DB::table('wallets')->delete(); $before = $this->snapshot(); $this->assertSame(-3, $this->refund($kind)['code']); $this->assertSame($before, $this->snapshot()); } #[DataProvider('kinds')] public function test_later_ledger_failure_rolls_back_the_entire_refund_batch(string $kind): void { $this->seed($kind); $this->seed($kind, 2); $before = $this->snapshot(); 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"); $this->assertSame(-3, $this->refund($kind, [1, 2])['code']); $this->assertSame($before, $this->snapshot()); } public function test_legacy_lottery_missing_total_is_derived_from_its_numbers(): void { $this->seed('lottery', coupon: false, missingTotal: true); $this->assertSame(0, $this->refund('lottery')['code']); $this->assertSame('350.00', bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2)); } }