coupon_refund_regression.php 4.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
  1. <?php
  2. // Isolated SQLite: execute real refunds/coupon writes, replacing only notification dispatch and the ledger sink.
  3. namespace App\Jobs { class SendTelegramMessageJob { public static int $sent = 0; public static function dispatch(...$args) { ++self::$sent; } } }
  4. namespace App\Services { class BalanceLogService { public static array $logs = []; public static function addLog(...$args) { self::$logs[] = $args; } } }
  5. namespace {
  6. require dirname(__DIR__, 2) . '/vendor/autoload.php';
  7. $app=new \Illuminate\Foundation\Application(dirname(__DIR__, 2));
  8. $app->instance('config',new \Illuminate\Config\Repository(['admin_realtime'=>['enabled'=>false]]));
  9. $app->instance('log',new \Psr\Log\NullLogger());
  10. \Illuminate\Support\Facades\Facade::setFacadeApplication($app);
  11. $db=new \Illuminate\Database\Capsule\Manager($app);
  12. $db->addConnection(['driver'=>'sqlite','database'=>':memory:','prefix'=>'bot_']); $db->setAsGlobal();$db->bootEloquent();
  13. $app->instance('db',$db->getDatabaseManager());$app->bind('db.schema',fn()=>$db->schema());
  14. $sql=[
  15. 'CREATE TABLE bot_wallets (id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,created_at TEXT,updated_at TEXT)',
  16. 'CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,issue TEXT,type INTEGER,lottery_status INTEGER,amount TEXT,total_amount TEXT,coupon_discount TEXT,coupon_id INTEGER,ordernum TEXT,number TEXT,created_at INTEGER,updated_at INTEGER)',
  17. 'CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,ordernum TEXT,lottery_status INTEGER,updated_at INTEGER)',
  18. 'CREATE TABLE bot_promotion_coupons (id INTEGER PRIMARY KEY,status TEXT,used_source TEXT,used_stake TEXT,used_at TEXT,updated_at TEXT)',
  19. 'CREATE TABLE bot_promotion_coupon_uses (id INTEGER PRIMARY KEY,coupon_id INTEGER,source TEXT,order_id INTEGER,discount TEXT,refunded_at TEXT,updated_at TEXT)',
  20. ];
  21. foreach($sql as $q) \Illuminate\Support\Facades\DB::statement($q);
  22. $DB=\Illuminate\Support\Facades\DB::class;
  23. $DB::table('wallets')->insert(['id'=>1,'member_id'=>'1001','available_balance'=>'0']);
  24. $DB::table('promotion_coupons')->insert(['id'=>1,'status'=>'used']);
  25. foreach([1=>['1','0'],2=>['10000','1']] as $id=>[$amount,$discount]) {
  26. $DB::table('lhc_order')->insert(['id'=>$id,'member_id'=>'1001','issue'=>'issue','type'=>5,'lottery_status'=>0,'amount'=>$amount,'total_amount'=>$id===1?'2':$amount,'coupon_discount'=>$discount,'coupon_id'=>1,'ordernum'=>'order'.$id,'created_at'=>time(),'updated_at'=>time()]);
  27. $DB::table('lhc_order_item')->insert(['id'=>$id,'ordernum'=>'order'.$id,'lottery_status'=>0]);
  28. $DB::table('promotion_coupon_uses')->insert(['id'=>$id,'coupon_id'=>1,'source'=>'lhc_order','order_id'=>$id,'discount'=>$discount]);
  29. }
  30. $DB::table('lhc_order_item')->insert(['id'=>3,'ordernum'=>'order1','lottery_status'=>0]);
  31. \App\Services\BetService::betFail('issue');
  32. $checks = 0;
  33. function same($actual, $expected, string $message): void {
  34. global $checks; ++$checks;
  35. if ($actual !== $expected) throw new \RuntimeException($message . ': ' . json_encode($actual));
  36. }
  37. same(bcadd((string)$DB::table('wallets')->value('available_balance'), '0', 2), '10001.00', 'All paid stakes, including zero-discount groups, are refunded');
  38. same($DB::table('promotion_coupons')->value('status'), 'unused', 'Coupon returns after all orders are refunded');
  39. same($DB::table('promotion_coupon_uses')->whereNull('refunded_at')->count(), 0, 'Zero-discount use is also refunded');
  40. same($DB::table('lhc_order')->orderBy('id')->pluck('lottery_status')->all(), [1, 1], 'Existing failed-draw terminal states are preserved');
  41. same(count(\App\Services\BalanceLogService::$logs), 2, 'One ledger entry per order');
  42. same(bcadd((string)\App\Services\BalanceLogService::$logs[0][1], '0', 2), '2.00', 'First ledger uses the complete paid group stake');
  43. same(\App\Jobs\SendTelegramMessageJob::$sent, 2, 'Notifications are stubbed, never sent externally');
  44. $DB::table('promotion_coupons')->update(['status' => 'used', 'used_source' => 'sport_order', 'used_stake' => '100']);
  45. $DB::table('promotion_coupon_uses')->insert(['id' => 3, 'coupon_id' => 1, 'source' => 'sport_order', 'order_id' => 9, 'discount' => '1']);
  46. \App\Services\PromotionCouponService::release('lhc_order', 1);
  47. same($DB::table('promotion_coupons')->value('status'), 'used', 'Retrying an old refund never restores a reused coupon');
  48. $DB::beginTransaction();
  49. \App\Services\PromotionCouponService::release('sport_order', 9);
  50. same($DB::table('promotion_coupons')->value('status'), 'unused', 'Nested release completes inside refund transaction');
  51. $DB::rollBack();
  52. same($DB::table('promotion_coupons')->value('status'), 'used', 'Outer rollback restores coupon state');
  53. same($DB::table('promotion_coupon_uses')->where('id', 3)->value('refunded_at'), null, 'Outer rollback restores use state');
  54. echo "PASS {$checks} coupon failed-draw refund checks\n";
  55. }