From 3f29229499d1dbde5883a6145ca0b02939a659c7 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 22 Jun 2026 09:48:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=83=A8=E5=88=86=E6=94=B6=E4=BB=98?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E9=87=8A=E9=A2=9D=E5=B9=B6=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E7=BB=93=E7=AE=97=20E2E=20=E4=B8=8E=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账期收付改为按累计已付同步 credit_ledger,修复多笔 partial_paid 与坏账核销重复释额;新增 API E2E(占成、权限、部分收付/坏账)与 GitHub Actions e2e-api job。 Co-authored-by: Cursor --- .github/workflows/e2e.yml | 57 +++++ .../Api/V1/E2E/E2EProvisionController.php | 84 ++++++++ .../AgentSettlementBadDebtService.php | 3 +- .../SettlementPaymentService.php | 4 +- app/Services/Player/PlayerCreditService.php | 99 +++++---- e2e/README.md | 47 ++-- e2e/database/seeders/E2EPlayerSeeder.php | 41 ++++ e2e/playwright.config.ts | 12 +- e2e/routes/e2e.php | 4 + e2e/scripts/run.sh | 57 +++-- e2e/tests/api/03.wallet-ticket.spec.ts | 14 +- e2e/tests/api/05.wallet-transfer.spec.ts | 2 +- e2e/tests/api/10.agent-settlement.spec.ts | 17 +- e2e/tests/api/14.settlement-payment.spec.ts | 17 +- e2e/tests/api/16.agent-share-bill.spec.ts | 79 +++++++ .../api/17.settlement-partial-payment.spec.ts | 202 ++++++++++++++++++ .../api/18.settlement-permissions.spec.ts | 151 +++++++++++++ .../19.settlement-bad-debt-partial.spec.ts | 105 +++++++++ e2e/tests/api/_helper.ts | 87 ++++++++ e2e/tests/api/helpers/draw-settlement.ts | 5 +- e2e/tests/ui/admin-settlement-center.spec.ts | 28 +++ e2e/tests/ui/front-place-bet.spec.ts | 49 +++++ .../SettlementPartialPaymentCreditTest.php | 198 +++++++++++++++++ 23 files changed, 1251 insertions(+), 111 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/tests/api/16.agent-share-bill.spec.ts create mode 100644 e2e/tests/api/17.settlement-partial-payment.spec.ts create mode 100644 e2e/tests/api/18.settlement-permissions.spec.ts create mode 100644 e2e/tests/api/19.settlement-bad-debt-partial.spec.ts create mode 100644 e2e/tests/ui/admin-settlement-center.spec.ts create mode 100644 e2e/tests/ui/front-place-bet.spec.ts create mode 100644 tests/Feature/SettlementPartialPaymentCreditTest.php diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..6b22ed9 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,57 @@ +name: lotterLaravel E2E + +on: + push: + branches: [main, master, develop] + pull_request: + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-api: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: pdo_pgsql, redis + coverage: none + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: e2e/package-lock.json + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist + + - name: Install Playwright dependencies + working-directory: e2e + run: npm ci + + - name: Install Playwright Chromium + working-directory: e2e + run: npx playwright install chromium --with-deps + + - name: Run E2E stack (API only) + env: + E2E_UI: "0" + CI: "true" + run: ./e2e/scripts/run.sh + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts + path: | + e2e/artifacts/ + e2e/logs/ + if-no-files-found: ignore diff --git a/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php b/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php index fd483de..b56f4ed 100644 --- a/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php +++ b/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php @@ -3,13 +3,16 @@ namespace App\Http\Controllers\Api\V1\E2E; use App\Models\AdminSite; +use App\Models\AdminUser; use App\Models\AgentNode; use App\Models\Player; use App\Models\PlayerWallet; use App\Services\Agent\AgentNodeService; use App\Services\Integration\PartnerSiteConfigResolver; use App\Support\ApiResponse; +use App\Support\PlatformSystemRoles; use App\Support\PlayerAuthSource; +use App\Support\SiteOperatorRoles; use Firebase\JWT\JWT; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -139,6 +142,87 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller ]); } + public function setupSiteOperator(Request $request): JsonResponse + { + PlatformSystemRoles::ensureAll(); + + $roleSlug = (string) ($request->input('role_slug') ?: SiteOperatorRoles::SLUG_SITE_FINANCE); + $username = (string) ($request->input('username') ?: 'e2e_site_finance'); + $password = (string) ($request->input('password') ?: env('E2E_PLAYER_PASSWORD', '12345678')); + $siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo')); + + $site = AdminSite::query()->where('code', $siteCode)->first(); + if ($site === null) { + return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + } + + $roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id'); + if ($roleId <= 0) { + return ApiResponse::error('role not found', 'e2e_role_missing', null, 500); + } + + /** @var AdminUser $user */ + $user = AdminUser::query()->updateOrCreate( + ['username' => $username], + [ + 'name' => 'E2E Site Operator', + 'email' => null, + 'password' => $password, + 'status' => 0, + ], + ); + + DB::table('admin_user_site_roles')->updateOrInsert( + ['admin_user_id' => $user->id, 'site_id' => $site->id], + ['role_id' => $roleId, 'granted_at' => now()], + ); + + return ApiResponse::success([ + 'admin_user_id' => (int) $user->id, + 'username' => $username, + 'password' => $password, + 'role_slug' => $roleSlug, + 'admin_site_id' => (int) $site->id, + 'site_code' => $siteCode, + ]); + } + + public function resetSiteSettlement(Request $request): JsonResponse + { + $siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo')); + $site = AdminSite::query()->where('code', $siteCode)->first(); + if ($site === null) { + return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + } + + $siteId = (int) $site->id; + $periodIds = DB::table('settlement_periods') + ->where('admin_site_id', $siteId) + ->pluck('id') + ->all(); + + if ($periodIds !== []) { + $billIds = DB::table('settlement_bills') + ->whereIn('settlement_period_id', $periodIds) + ->pluck('id') + ->all(); + + if ($billIds !== []) { + DB::table('payment_records')->whereIn('settlement_bill_id', $billIds)->delete(); + } + + DB::table('settlement_adjustments')->whereIn('settlement_period_id', $periodIds)->delete(); + DB::table('settlement_bills')->whereIn('settlement_period_id', $periodIds)->delete(); + DB::table('settlement_periods')->where('admin_site_id', $siteId)->delete(); + } + + return ApiResponse::success([ + 'site_code' => $siteCode, + 'admin_site_id' => $siteId, + 'cleared_periods' => count($periodIds), + ]); + } + public function mintSsoJwt(Request $request): JsonResponse { $siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo')); diff --git a/app/Services/AgentSettlement/AgentSettlementBadDebtService.php b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php index 3078d5d..4cf48c0 100644 --- a/app/Services/AgentSettlement/AgentSettlementBadDebtService.php +++ b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php @@ -105,7 +105,8 @@ final class AgentSettlementBadDebtService if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) { $player = Player::query()->find((int) $original->owner_id); if ($player !== null) { - $this->playerCreditService->releaseFromSettlement($player, $unpaid, $originalBillId); + $cumulativePaid = (int) $original->paid_amount + $unpaid; + $this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId); } } diff --git a/app/Services/AgentSettlement/SettlementPaymentService.php b/app/Services/AgentSettlement/SettlementPaymentService.php index 782b80a..a6a48a2 100644 --- a/app/Services/AgentSettlement/SettlementPaymentService.php +++ b/app/Services/AgentSettlement/SettlementPaymentService.php @@ -86,9 +86,9 @@ final class SettlementPaymentService $player = Player::query()->find((int) $bill->owner_id); if ($player !== null) { if ((int) $bill->net_amount > 0) { - $this->playerCreditService->releaseFromSettlement($player, $payAmount, $billId); + $this->playerCreditService->releaseFromSettlement($player, $newPaid, $billId); } elseif ((int) $bill->net_amount < 0) { - $this->playerCreditService->applySettlementPayout($player, $payAmount, $billId); + $this->playerCreditService->applySettlementPayout($player, $newPaid, $billId); } if ($status === 'settled') { diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index 4a3ed6f..d433328 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -359,62 +359,89 @@ final class PlayerCreditService ]); } - public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void + /** + * @param int $cumulativePaidMinor 账单累计已登记收付(minor),支持部分收付多笔递增。 + */ + public function releaseFromSettlement(Player $player, int $cumulativePaidMinor, int $billId): void { - if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { + if ($cumulativePaidMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { return; } - // 幂等闸门:credit_ledger (settlement_bill, settlement_confirm) 唯一;重复调用直接返回。 - try { - DB::table('credit_ledger')->insert([ - 'owner_type' => 'player', - 'owner_id' => $player->id, - 'amount' => $amountMinor, - 'reason' => 'settlement_confirm', - 'ref_type' => 'settlement_bill', - 'ref_id' => $billId, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } catch (QueryException $e) { - if ($this->isUniqueViolation($e)) { - return; - } - throw $e; + $delta = $this->syncSettlementBillLedger( + $player, + $billId, + 'settlement_confirm', + $cumulativePaidMinor, + ); + if ($delta > 0) { + $this->decreaseUsedCredit($player, $delta); } - - $this->decreaseUsedCredit($player, $amountMinor); } - public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void + /** + * @param int $cumulativePaidMinor 账单累计已登记收付(minor),支持部分收付多笔递增。 + */ + public function applySettlementPayout(Player $player, int $cumulativePaidMinor, int $billId): void { - if ($amountMinor <= 0) { + if ($cumulativePaidMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { return; } - if (! PlayerFundingMode::usesCredit($player)) { - return; + $this->syncSettlementBillLedger( + $player, + $billId, + 'settlement_payout', + $cumulativePaidMinor, + ); + } + + /** + * 同步账期收付台账:每账单每种 reason 仅一行,amount 为累计已付;返回本次应追加的 minor 增量。 + */ + private function syncSettlementBillLedger( + Player $player, + int $billId, + string $reason, + int $cumulativeMinor, + ): int { + $existing = DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $player->id) + ->where('reason', $reason) + ->where('ref_type', 'settlement_bill') + ->where('ref_id', $billId) + ->lockForUpdate() + ->first(); + + $recorded = $existing !== null ? (int) $existing->amount : 0; + $delta = $cumulativeMinor - $recorded; + if ($delta <= 0) { + return 0; } - // 幂等闸门:credit_ledger (settlement_bill, settlement_payout) 唯一;重复入账直接返回。 - try { + $now = now(); + if ($existing === null) { DB::table('credit_ledger')->insert([ 'owner_type' => 'player', 'owner_id' => $player->id, - 'amount' => $amountMinor, - 'reason' => 'settlement_payout', + 'amount' => $cumulativeMinor, + 'reason' => $reason, 'ref_type' => 'settlement_bill', 'ref_id' => $billId, - 'created_at' => now(), - 'updated_at' => now(), + 'created_at' => $now, + 'updated_at' => $now, ]); - } catch (QueryException $e) { - if ($this->isUniqueViolation($e)) { - return; - } - throw $e; + } else { + DB::table('credit_ledger') + ->where('id', (int) $existing->id) + ->update([ + 'amount' => $cumulativeMinor, + 'updated_at' => $now, + ]); } + + return $delta; } private function decreaseUsedCredit(Player $player, int $amountMinor): void diff --git a/e2e/README.md b/e2e/README.md index 5396527..2c0114c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -44,11 +44,17 @@ e2e/ │ │ ├── 13.credit-settlement-win.spec.ts # 信用盘中奖释额 │ │ ├── 14.settlement-payment.spec.ts # 账期 confirm + 收付 │ │ ├── 15.reconcile-job.spec.ts # pending_reconcile 扫描 +│ │ ├── 16.agent-share-bill.spec.ts # 代理占成账单 +│ │ ├── 17.settlement-partial-payment.spec.ts # 部分收付 +│ │ ├── 18.settlement-permissions.spec.ts # 站点财务/代理权限 +│ │ ├── 19.settlement-bad-debt-partial.spec.ts # 部分收付后坏账 │ │ ├── _helper.ts # 共享步骤 │ │ └── helpers/draw-settlement.ts # 结算流水线 │ └── ui/ │ ├── admin-login.spec.ts -│ └── front-login-hall.spec.ts +│ ├── admin-settlement-center.spec.ts +│ ├── front-login-hall.spec.ts +│ └── front-place-bet.spec.ts ├── database/seeders/ │ └── E2EPlayerSeeder.php # 建可登录玩家(E2E\Seeders 命名空间) ├── routes/e2e.php # 仅 /api/v1/_e2e/* 路由 @@ -98,8 +104,8 @@ composer.json # autoload-dev 加 E2E\Seeders\, E2E\Pr 6. **`php artisan lottery:db-init --fresh`** ⚠️ 重建 `lottery_e2e` 库(**只**作用于此库,绝不碰其他库) 7. 跑 `E2EPlayerSeeder`(建可登录玩家) 8. 起 `php artisan serve`(8000)、`queue:work redis`(默认队列)、`reverb:start`(8080) -9. `npx playwright install chromium`(首次) -10. `npx playwright test` +9. UI 测试默认用本机 **Google Chrome**(`playwright.config.ts` `channel: 'chrome'`),**无需**下载 Playwright Chromium +10. `npx playwright test`(若要用自带 Chromium:`PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 ./e2e/scripts/run.sh`) 跑完按 Ctrl+C 自动停 serve/queue/reverb。`docker compose down -v` 自行决定(脚本不删 volume,下次跑快)。 @@ -144,7 +150,7 @@ PLAYWRIGHT_API_URL=http://127.0.0.1:8000 \ | 玩家登录 / 失败 / 锁定 | 02.player-auth | 4 | ✅ | | 玩家钱包 + 下注 + 幂等 | 03.wallet-ticket | 3 | ✅ | | 超管登录 / dashboard / 401 | 04.admin-auth | 3 | ✅ | -| 玩家 transfer-in/out + 1001/1010 | 05.wallet-transfer | 7 (+2 skip) | ✅ | +| 玩家 transfer-in/out + 1001/1010 | 05.wallet-transfer | 7 | ✅ | | 钱包流水一致性 + 过滤 + 分页 | 06.wallet-logs | 4 | ✅ | | 超管创建/冻结/解冻/查玩家 | 07.admin-player | 6 | ✅ | | 开奖+结算+派彩 完整链路 | 08.draw-publish-settle | 1 | ✅ 确定性 poll | @@ -155,10 +161,16 @@ PLAYWRIGHT_API_URL=http://127.0.0.1:8000 \ | 信用盘中奖释额 game_settlement_win | 13.credit-settlement-win | 1 | ✅ | | 账期 confirm + 登记收付闭环 | 14.settlement-payment | 1 | ✅ | | pending_reconcile → reconcile-jobs | 15.reconcile-job | 1 | ✅ | +| 代理占成账单 share_profit | 16.agent-share-bill | 1 | ✅ | +| 部分收付 partial_paid → settled | 17.settlement-partial-payment | 2 | ✅ | +| 站点财务收付 / 绑定代理禁坏账 | 18.settlement-permissions | 1 | ✅ | +| 部分收付后坏账核销 | 19.settlement-bad-debt-partial | 1 | ✅ | | 管理端 UI 登录 | ui/admin-login | 1 | ✅ | +| 管理端 UI 结算中心 | ui/admin-settlement-center | 1 | ✅ | | 玩家端 UI 登录进大厅 | ui/front-login-hall | 1 | ✅ | +| 玩家端 UI 下注提交 | ui/front-place-bet | 1 | ✅ | -合计 ~36 个用例覆盖 e2e 关键链路。 +合计 ~43 个用例覆盖 e2e 关键链路。 ## 已知未覆盖(需外部依赖或重构成本高) @@ -194,15 +206,20 @@ npx playwright test 05.wallet-transfer.spec.ts --headed - **测试卡住**:脚本的 `cleanup` 钩子 Ctrl+C 会停 artisan/queue/reverb - **player 登录 401**:`curl -X POST http://127.0.0.1:8000/api/v1/_e2e/reset-player` 重置玩家 -## CI 集成(参考) +## CI 集成 -```yaml -# .github/workflows/e2e.yml -- name: e2e - run: ./e2e/scripts/run.sh -- uses: actions/upload-artifact@v4 - if: failure() - with: - name: e2e-artifacts - path: e2e/artifacts/ +仓库已包含 `.github/workflows/e2e.yml`(push/PR 自动跑 API 项目;`E2E_UI=0` 不启前后端 dev server)。 + +本地跑全量(含 UI): + +```bash +./e2e/scripts/run.sh ``` + +仅 API(与 CI 一致): + +```bash +E2E_UI=0 ./e2e/scripts/run.sh +``` + +失败产物:`e2e/artifacts/`、`e2e/logs/`。 diff --git a/e2e/database/seeders/E2EPlayerSeeder.php b/e2e/database/seeders/E2EPlayerSeeder.php index c09ef1f..fd7bbb6 100644 --- a/e2e/database/seeders/E2EPlayerSeeder.php +++ b/e2e/database/seeders/E2EPlayerSeeder.php @@ -3,8 +3,10 @@ namespace E2E\Seeders; use App\Models\AdminSite; +use App\Models\AdminUser; use App\Models\Player; use App\Models\PlayerWallet; +use App\Services\Agent\AgentNodeService; use App\Support\PlayerAuthSource; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; @@ -28,6 +30,7 @@ final class E2EPlayerSeeder extends Seeder $mockWalletPort = (string) env('E2E_MOCK_WALLET_PORT', '5555'); $this->ensureDemoIntegrationSite($siteCode, $currency, $mockWalletPort); + $this->ensureE2ELeafAgent($siteCode, $password); // LocalDemoSeeder 可能已建无 password_hash 的 demo_player;合并为一条可登录行。 Player::query() @@ -135,4 +138,42 @@ final class E2EPlayerSeeder extends Seeder ]); } } + + private function ensureE2ELeafAgent(string $siteCode, string $password): void + { + $siteId = (int) AdminSite::query()->where('code', $siteCode)->value('id'); + if ($siteId <= 0) { + return; + } + + $exists = DB::table('admin_users')->where('username', 'e2e_leaf_agent')->exists(); + if ($exists) { + return; + } + + $rootId = (int) DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->value('id'); + if ($rootId <= 0) { + return; + } + + $super = AdminUser::query()->where('username', 'admin')->first(); + if ($super === null) { + return; + } + + app(AgentNodeService::class)->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'e2e_leaf', + 'name' => 'E2E Leaf Agent', + 'username' => 'e2e_leaf_agent', + 'password' => $password, + 'total_share_rate' => 25, + 'credit_limit' => 200_000, + 'rebate_limit' => 0.01, + 'default_player_rebate' => 0.005, + ]); + } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 8e21485..6f1f655 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -5,6 +5,14 @@ const ADMIN_URL = process.env.PLAYWRIGHT_ADMIN_URL ?? 'http://localhost:3801'; const FRONT_URL = process.env.PLAYWRIGHT_FRONT_URL ?? 'http://localhost:3800'; const ARTIFACT_DIR = 'artifacts'; +/** 默认用本机 Google Chrome;设 PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 则走 ms-playwright 自带 Chromium。 */ +const uiBrowserUse = { + ...devices['Desktop Chrome'], + ...(process.env.PLAYWRIGHT_USE_BUNDLED_CHROMIUM === '1' + ? {} + : { channel: 'chrome' as const }), +}; + export default defineConfig({ testDir: './tests', globalSetup: './global-setup.ts', @@ -40,7 +48,7 @@ export default defineConfig({ testMatch: 'tests/ui/admin*.spec.ts', use: { baseURL: ADMIN_URL, - ...devices['Desktop Chrome'], + ...uiBrowserUse, }, }, { @@ -48,7 +56,7 @@ export default defineConfig({ testMatch: 'tests/ui/front*.spec.ts', use: { baseURL: FRONT_URL, - ...devices['Desktop Chrome'], + ...uiBrowserUse, }, }, ], diff --git a/e2e/routes/e2e.php b/e2e/routes/e2e.php index cff8f79..3fb381b 100644 --- a/e2e/routes/e2e.php +++ b/e2e/routes/e2e.php @@ -65,6 +65,10 @@ Route::prefix('api/v1/_e2e') // 信用盘 / SSO / 主站钱包 mock Route::post('credit-player/setup', [E2EProvisionController::class, 'setupCreditPlayer']) ->name('e2e.credit-player.setup'); + Route::post('site-operator/setup', [E2EProvisionController::class, 'setupSiteOperator']) + ->name('e2e.site-operator.setup'); + Route::post('settlement/reset', [E2EProvisionController::class, 'resetSiteSettlement']) + ->name('e2e.settlement.reset'); Route::post('sso/mint-jwt', [E2EProvisionController::class, 'mintSsoJwt']) ->name('e2e.sso.mint-jwt'); Route::post('site/wallet-api', [E2EProvisionController::class, 'configureWalletApi']) diff --git a/e2e/scripts/run.sh b/e2e/scripts/run.sh index cbf2970..a8505a5 100755 --- a/e2e/scripts/run.sh +++ b/e2e/scripts/run.sh @@ -6,7 +6,7 @@ # 4. php artisan serve 起应用 # 5. 启动 queue:work(异步开奖/广播) # 6. 启动 reverb:start -# 7. npx playwright install chromium(首次) +# 7. npx playwright install chromium(仅缺浏览器缓存时) # 8. npx playwright test # 9. 失败时打 docker logs + Laravel 日志;结束时清理 compose @@ -49,6 +49,24 @@ require php require node require npx +playwright_cache_dir() { + case "$(uname -s)" in + Darwin) echo "$HOME/Library/Caches/ms-playwright" ;; + Linux) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;; + MINGW*|MSYS*|CYGWIN*) echo "${LOCALAPPDATA:-$HOME/AppData/Local}/ms-playwright" ;; + *) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;; + esac +} + +playwright_chromium_cached() { + local cache_dir + cache_dir="$(playwright_cache_dir)" + # UI 项目依赖 chromium_headless_shell;API 仅 request 时可能不触发,但一并检测。 + compgen -G "${cache_dir}/chromium-"* >/dev/null 2>&1 \ + || compgen -G "${cache_dir}/chromium_headless_shell-"* >/dev/null 2>&1 \ + || [[ -d "$E2E_DIR/node_modules/playwright-core/.local-browsers" ]] +} + echo "==> [1/8] docker compose up -d" if ! docker image inspect postgres:16-alpine >/dev/null 2>&1 \ || ! docker image inspect redis:7-alpine >/dev/null 2>&1; then @@ -224,22 +242,31 @@ cd "$E2E_DIR" if [[ ! -d node_modules ]]; then npm install fi -if [[ ! -d node_modules/@playwright/test/.local-browsers ]]; then - npx playwright install chromium +# UI 默认 channel:chrome(本机 Google Chrome);仅显式要求 bundled 时才下载 ms-playwright Chromium。 +if [[ "${PLAYWRIGHT_USE_BUNDLED_CHROMIUM:-0}" == "1" ]]; then + if ! playwright_chromium_cached; then + export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}" + npx playwright install chromium + else + export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}" + fi fi -PLAYWRIGHT_JWT_SECRET=$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-) \ -PLAYWRIGHT_API_URL="$API_URL" \ -PLAYWRIGHT_ADMIN_URL="http://localhost:3801" \ -PLAYWRIGHT_FRONT_URL="http://localhost:3800" \ -REVERB_APP_KEY=$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-) \ -REVERB_HOST=127.0.0.1 \ -REVERB_PORT=8080 \ -E2E_MOCK_WALLET_PORT=5555 \ -E2E_ADMIN_USERNAME=admin \ -E2E_ADMIN_PASSWORD=12345678 \ -E2E_PLAYER_USERNAME=$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-) \ -E2E_PLAYER_PASSWORD=$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-) \ +export PLAYWRIGHT_JWT_SECRET="$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-)" +export PLAYWRIGHT_API_URL="$API_URL" +export PLAYWRIGHT_ADMIN_URL="http://localhost:3801" +export PLAYWRIGHT_FRONT_URL="http://localhost:3800" +export REVERB_APP_KEY="$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-)" +export REVERB_HOST=127.0.0.1 +export REVERB_PORT=8080 +export E2E_MOCK_WALLET_PORT=5555 +export E2E_ADMIN_USERNAME=admin +export E2E_ADMIN_PASSWORD=12345678 +export E2E_PLAYER_USERNAME="$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-)" +export E2E_PLAYER_PASSWORD="$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-)" +if [[ "${E2E_UI:-1}" != "1" ]]; then + set -- --project=api "$@" +fi npx playwright test "$@" rc=$? diff --git a/e2e/tests/api/03.wallet-ticket.spec.ts b/e2e/tests/api/03.wallet-ticket.spec.ts index d744e74..1f91d63 100644 --- a/e2e/tests/api/03.wallet-ticket.spec.ts +++ b/e2e/tests/api/03.wallet-ticket.spec.ts @@ -14,7 +14,7 @@ */ import { test, expect, request as pwRequest } from '@playwright/test'; -import { playerLoginViaBypass, resetE2EPlayer, fetchCurrentDrawNo } from './_helper'; +import { playerLoginViaBypass, resetE2EPlayer, fetchOpenDrawNo } from './_helper'; const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase(); const BET_AMOUNT_MINOR = 1000; @@ -39,8 +39,7 @@ test('玩家登录 → /wallet/balance 返回币种 + 余额', async () => { }); test('preview → place → 拿 ticket_no + 余额变动', async () => { - const draw = await fetchCurrentDrawNo(); - test.skip(draw === null, '当前无开放期号(draw.current 返回 null),跳过下注链路'); + const drawNo = await fetchOpenDrawNo(); const session = await playerLoginViaBypass(); const ctx = await pwRequest.newContext({ @@ -52,7 +51,7 @@ test('preview → place → 拿 ticket_no + 余额变动', async () => { const preview = await ctx.post('/api/v1/ticket/preview', { data: { - draw_id: draw!.draw_no, + draw_id: drawNo, currency_code: CURRENCY, client_trace_id: `e2e-preview-${Date.now()}`, lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT_MINOR }], @@ -64,7 +63,7 @@ test('preview → place → 拿 ticket_no + 余额变动', async () => { const place = await ctx.post('/api/v1/ticket/place', { data: { - draw_id: draw!.draw_no, + draw_id: drawNo, currency_code: CURRENCY, client_trace_id: `e2e-place-${Date.now()}`, lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT_MINOR }], @@ -89,8 +88,7 @@ test('preview → place → 拿 ticket_no + 余额变动', async () => { }); test('同 client_trace_id 第二次 place → 幂等回放,不重复扣款', async () => { - const draw = await fetchCurrentDrawNo(); - test.skip(draw === null, '当前无开放期号,跳过幂等用例'); + const drawNo = await fetchOpenDrawNo(); const session = await playerLoginViaBypass(); const ctx = await pwRequest.newContext({ @@ -99,7 +97,7 @@ test('同 client_trace_id 第二次 place → 幂等回放,不重复扣款', a const trace = `e2e-idem-${Date.now()}`; const payload = { - draw_id: draw!.draw_no, + draw_id: drawNo, currency_code: CURRENCY, client_trace_id: trace, lines: [{ number: '5678', play_code: 'straight', amount: 500 }], diff --git a/e2e/tests/api/05.wallet-transfer.spec.ts b/e2e/tests/api/05.wallet-transfer.spec.ts index f29a3e4..8eabe16 100644 --- a/e2e/tests/api/05.wallet-transfer.spec.ts +++ b/e2e/tests/api/05.wallet-transfer.spec.ts @@ -9,7 +9,7 @@ * 5. 余额不足 1001:把 balance 改到 0 → transfer-out → 1001 * 6. 幂等冲突 1010:同 idempotent_key 第二次 amount 不同 → 1010 * - * 不覆盖(需外部主站 mock 才能跑,留 skip + 文档): + * 不覆盖(需外部主站 mock 或改配置才能跑): * - 主站失败 1009(需真主站返 5xx) * - 主站超时 504/408 → 1002 pending_reconcile(需真主站返 timeout) * - 转入关 1004(需 .env 关 transfer_in_enabled,跑前要改) diff --git a/e2e/tests/api/10.agent-settlement.spec.ts b/e2e/tests/api/10.agent-settlement.spec.ts index 1012c6b..ea7dad5 100644 --- a/e2e/tests/api/10.agent-settlement.spec.ts +++ b/e2e/tests/api/10.agent-settlement.spec.ts @@ -8,6 +8,7 @@ import { adminCtxOf, fetchOpenDrawNo, playerLoginViaBypass, + openSettlementPeriod, setupCreditPlayer, } from './_helper'; import { runWalletDrawSettlement } from './helpers/draw-settlement'; @@ -20,21 +21,7 @@ test('信用下注结算后关账 → 生成玩家 settlement_bill', async () => const adminSession = await adminLoginViaBypass(); const admin = await adminCtxOf(adminSession.accessToken); - - const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19); - const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19); - - const open = await admin.post('/api/v1/admin/settlement-periods', { - data: { - admin_site_id: credit.admin_site_id, - period_start: start, - period_end: end, - }, - }); - expect(open.ok(), `open period status=${open.status()}`).toBeTruthy(); - const openBody = (await open.json()) as any; - expect(openBody.code).toBe(0); - const periodId = openBody.data.id; + const periodId = await openSettlementPeriod(admin, credit.admin_site_id, credit.site_code); const playerSession = await playerLoginViaBypass({ site_code: credit.site_code, diff --git a/e2e/tests/api/14.settlement-payment.spec.ts b/e2e/tests/api/14.settlement-payment.spec.ts index 5efea35..61ca581 100644 --- a/e2e/tests/api/14.settlement-payment.spec.ts +++ b/e2e/tests/api/14.settlement-payment.spec.ts @@ -8,6 +8,7 @@ import { adminCtxOf, fetchOpenDrawNo, playerLoginViaBypass, + openSettlementPeriod, setupCreditPlayer, } from './_helper'; import { runWalletDrawSettlement } from './helpers/draw-settlement'; @@ -24,21 +25,7 @@ test('关账后 confirm + 全额收付 → 玩家账单 settled + payment_record const adminSession = await adminLoginViaBypass(); const admin = await adminCtxOf(adminSession.accessToken); - - const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19); - const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19); - - const open = await admin.post('/api/v1/admin/settlement-periods', { - data: { - admin_site_id: credit.admin_site_id, - period_start: start, - period_end: end, - }, - }); - expect(open.ok(), `open period status=${open.status()}`).toBeTruthy(); - const openBody = (await open.json()) as any; - expect(openBody.code).toBe(0); - const periodId = openBody.data.id; + const periodId = await openSettlementPeriod(admin, credit.admin_site_id); const playerSession = await playerLoginViaBypass({ site_code: credit.site_code, diff --git a/e2e/tests/api/16.agent-share-bill.spec.ts b/e2e/tests/api/16.agent-share-bill.spec.ts new file mode 100644 index 0000000..d2b51d4 --- /dev/null +++ b/e2e/tests/api/16.agent-share-bill.spec.ts @@ -0,0 +1,79 @@ +/** + * E2E:信用下注结算后关账 → 玩家账单 + 代理占成账单(share_profit)。 + */ + +import { test, expect } from '@playwright/test'; +import { + adminLoginViaBypass, + adminCtxOf, + fetchOpenDrawNo, + playerLoginViaBypass, + openSettlementPeriod, + setupCreditPlayer, +} from './_helper'; +import { runWalletDrawSettlement } from './helpers/draw-settlement'; + +test('关账后生成玩家账单与代理占成账单', async () => { + test.setTimeout(300_000); + + const credit = await setupCreditPlayer({ + credit_limit: 100_000, + username: `e2e_share_${Date.now()}`, + }); + const drawNo = await fetchOpenDrawNo(); + + const adminSession = await adminLoginViaBypass(); + const admin = await adminCtxOf(adminSession.accessToken); + const periodId = await openSettlementPeriod(admin, credit.admin_site_id); + + const playerSession = await playerLoginViaBypass({ + site_code: credit.site_code, + username: credit.username, + password: credit.password, + }); + + await runWalletDrawSettlement({ + adminToken: adminSession.accessToken, + playerToken: playerSession.access_token, + drawNo, + betNumber: '5678', + betAmount: 10_000, + expectWalletIncrease: false, + }); + + const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); + expect(close.ok(), `close period status=${close.status()}`).toBeTruthy(); + const closeBody = (await close.json()) as any; + expect(closeBody.code).toBe(0); + + const bills = await admin.get( + `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=50`, + ); + const billsBody = (await bills.json()) as any; + expect(billsBody.code).toBe(0); + + const playerBill = billsBody.data.items.find( + (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, + ); + expect(playerBill, '应有该信用玩家的账单').toBeTruthy(); + + const agentBills = billsBody.data.items.filter((b: any) => b.bill_type === 'agent'); + expect(agentBills.length, '关账后应生成代理占成账单').toBeGreaterThan(0); + + const leafEdge = agentBills.find( + (b: any) => + Number(b.owner_id) === credit.agent_node_id || Number(b.counterparty_id) === credit.agent_node_id, + ); + expect(leafEdge, '应包含直属代理节点的占成边').toBeTruthy(); + + const meta = + typeof leafEdge.meta_json === 'string' + ? JSON.parse(leafEdge.meta_json) + : leafEdge.meta_json ?? {}; + expect( + 'share_profit' in meta || Number(leafEdge.net_amount) !== 0, + '代理账单应含占成信息或非零净额', + ).toBeTruthy(); + + await admin.dispose(); +}); diff --git a/e2e/tests/api/17.settlement-partial-payment.spec.ts b/e2e/tests/api/17.settlement-partial-payment.spec.ts new file mode 100644 index 0000000..947861f --- /dev/null +++ b/e2e/tests/api/17.settlement-partial-payment.spec.ts @@ -0,0 +1,202 @@ +/** + * E2E:账期账单部分收付 → partial_paid → 尾款结清 settled。 + */ + +import { test, expect } from '@playwright/test'; +import { + adminLoginViaBypass, + adminCtxOf, + fetchBillCreditLedgerRows, + fetchOpenDrawNo, + playerLoginViaBypass, + openSettlementPeriod, + setupCreditPlayer, +} from './_helper'; +import { runWalletDrawSettlement } from './helpers/draw-settlement'; + +test('confirm 后分两笔收付 → partial_paid → settled', async () => { + test.setTimeout(300_000); + + const credit = await setupCreditPlayer({ + credit_limit: 100_000, + username: `e2e_partial_${Date.now()}`, + }); + const drawNo = await fetchOpenDrawNo(); + + const adminSession = await adminLoginViaBypass(); + const admin = await adminCtxOf(adminSession.accessToken); + const periodId = await openSettlementPeriod(admin, credit.admin_site_id); + + const playerSession = await playerLoginViaBypass({ + site_code: credit.site_code, + username: credit.username, + password: credit.password, + }); + + await runWalletDrawSettlement({ + adminToken: adminSession.accessToken, + playerToken: playerSession.access_token, + drawNo, + betNumber: '1234', + publishWinNumber: '9999', + betAmount: 10_000, + expectWalletIncrease: false, + }); + + const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); + expect(close.ok()).toBeTruthy(); + expect((await close.json()).code).toBe(0); + + const bills = await admin.get( + `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, + ); + const billsBody = (await bills.json()) as any; + const playerBill = billsBody.data.items.find( + (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, + ); + expect(playerBill).toBeTruthy(); + expect(Number(playerBill.net_amount)).toBeGreaterThan(0); + + const confirm = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`); + expect(confirm.ok()).toBeTruthy(); + const confirmBody = (await confirm.json()) as any; + expect(confirmBody.code).toBe(0); + expect(confirmBody.data.status).toBe('confirmed'); + + const billShow = await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`); + const billDetail = (await billShow.json()) as any; + const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount)); + expect(unpaid).toBeGreaterThan(1); + const firstPay = Math.floor(unpaid / 2); + expect(firstPay).toBeGreaterThan(0); + + const pay1 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { + data: { amount: firstPay, method: 'e2e_partial_1', remark: 'e2e first half' }, + }); + expect(pay1.ok()).toBeTruthy(); + const pay1Body = (await pay1.json()) as any; + expect(pay1Body.code).toBe(0); + expect(pay1Body.data.bill.status).toBe('partial_paid'); + + const ledgerAfterPay1 = await fetchBillCreditLedgerRows( + credit.player_id, + Number(playerBill.id), + 'settlement_confirm', + ); + expect(ledgerAfterPay1).toHaveLength(1); + expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay); + + const midShow = await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`); + const midDetail = (await midShow.json()) as any; + const secondPay = Math.abs(Number(midDetail.data.bill.unpaid_amount)); + expect(secondPay).toBeGreaterThan(0); + + const pay2 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { + data: { amount: secondPay, method: 'e2e_partial_2', remark: 'e2e remainder' }, + }); + const pay2Text = await pay2.text(); + expect(pay2.ok(), `pay2 status=${pay2.status()} body=${pay2Text.slice(0, 400)}`).toBeTruthy(); + const pay2Body = JSON.parse(pay2Text) as any; + expect(pay2Body.data.bill.status).toBe('settled'); + expect(Number(pay2Body.data.bill.unpaid_amount)).toBe(0); + + const ledgerAfterPay2 = await fetchBillCreditLedgerRows( + credit.player_id, + Number(playerBill.id), + 'settlement_confirm', + ); + expect(ledgerAfterPay2).toHaveLength(1); + expect(Number(ledgerAfterPay2[0].amount)).toBe(unpaid); + + const exceed = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { + data: { amount: 1, method: 'e2e_over', remark: 'should fail' }, + }); + const exceedBody = (await exceed.json()) as any; + expect(exceedBody.code).not.toBe(0); + + await admin.dispose(); +}); + +test('玩家赢单 confirm 后分两笔收付 → settlement_payout 累计', async () => { + test.setTimeout(300_000); + + const winNumber = `6${String(Date.now()).slice(-3)}`; + const credit = await setupCreditPlayer({ + credit_limit: 100_000, + username: `e2e_partial_win_${Date.now()}`, + }); + const drawNo = await fetchOpenDrawNo(); + + const adminSession = await adminLoginViaBypass(); + const admin = await adminCtxOf(adminSession.accessToken); + const periodId = await openSettlementPeriod(admin, credit.admin_site_id); + + const playerSession = await playerLoginViaBypass({ + site_code: credit.site_code, + username: credit.username, + password: credit.password, + }); + + await runWalletDrawSettlement({ + adminToken: adminSession.accessToken, + playerToken: playerSession.access_token, + drawNo, + betNumber: winNumber, + betAmount: 10_000, + expectWalletIncrease: false, + }); + + const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); + expect(close.ok()).toBeTruthy(); + + const bills = await admin.get( + `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, + ); + const billsBody = (await bills.json()) as any; + const playerBill = billsBody.data.items.find( + (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, + ); + expect(playerBill).toBeTruthy(); + expect(Number(playerBill.net_amount)).toBeLessThan(0); + + await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`); + + const billDetail = (await (await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`)).json()) as any; + const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount)); + expect(unpaid).toBeGreaterThan(1); + const firstPay = Math.floor(unpaid / 2); + expect(firstPay).toBeGreaterThan(0); + + const pay1 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { + data: { amount: firstPay, method: 'e2e_win_partial_1', remark: 'win first half' }, + }); + expect(pay1.ok()).toBeTruthy(); + expect((await pay1.json()).data.bill.status).toBe('partial_paid'); + + const ledgerAfterPay1 = await fetchBillCreditLedgerRows( + credit.player_id, + Number(playerBill.id), + 'settlement_payout', + ); + expect(ledgerAfterPay1).toHaveLength(1); + expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay); + + const midDetail = (await (await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`)).json()) as any; + const secondPay = Math.abs(Number(midDetail.data.bill.unpaid_amount)); + + const pay2 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { + data: { amount: secondPay, method: 'e2e_win_partial_2', remark: 'win remainder' }, + }); + expect(pay2.ok()).toBeTruthy(); + expect((await pay2.json()).data.bill.status).toBe('settled'); + + const ledgerAfterPay2 = await fetchBillCreditLedgerRows( + credit.player_id, + Number(playerBill.id), + 'settlement_payout', + ); + expect(ledgerAfterPay2).toHaveLength(1); + expect(Number(ledgerAfterPay2[0].amount)).toBe(unpaid); + + await admin.dispose(); +}); diff --git a/e2e/tests/api/18.settlement-permissions.spec.ts b/e2e/tests/api/18.settlement-permissions.spec.ts new file mode 100644 index 0000000..e207884 --- /dev/null +++ b/e2e/tests/api/18.settlement-permissions.spec.ts @@ -0,0 +1,151 @@ +/** + * E2E:结算收付权限 — 站点财务可收付/坏账;绑定代理不可坏账核销。 + */ + +import { test, expect } from '@playwright/test'; +import { + adminCtxOf, + adminLoginWithAccount, + adminLoginViaBypass, + fetchOpenDrawNo, + openSettlementPeriod, + playerLoginViaBypass, + setupCreditPlayer, + setupSiteOperator, +} from './_helper'; +import { runWalletDrawSettlement } from './helpers/draw-settlement'; + +const DEFAULT_PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678'; + +test('站点财务可 confirm + 收付;绑定代理不可坏账核销', async () => { + test.setTimeout(360_000); + + const credit = await setupCreditPlayer({ + credit_limit: 100_000, + username: `e2e_perm_${Date.now()}`, + }); + const finance = await setupSiteOperator({ + username: `e2e_fin_${Date.now()}`, + password: DEFAULT_PASS, + role_slug: 'site_finance', + }); + const drawNo = await fetchOpenDrawNo(); + const winNumber = `5${String(Date.now()).slice(-3)}`; + + const superSession = await adminLoginViaBypass(); + const superAdmin = await adminCtxOf(superSession.accessToken); + const periodId = await openSettlementPeriod(superAdmin, credit.admin_site_id); + + const playerSession = await playerLoginViaBypass({ + site_code: credit.site_code, + username: credit.username, + password: credit.password, + }); + + await runWalletDrawSettlement({ + adminToken: superSession.accessToken, + playerToken: playerSession.access_token, + drawNo, + betNumber: winNumber, + betAmount: 10_000, + expectWalletIncrease: false, + }); + + const close = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); + expect(close.ok()).toBeTruthy(); + + const bills = await superAdmin.get( + `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, + ); + const billsBody = (await bills.json()) as any; + const playerBill = billsBody.data.items.find( + (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, + ); + expect(playerBill).toBeTruthy(); + const billId = Number(playerBill.id); + const unpaid = Number(playerBill.unpaid_amount); + expect(unpaid).toBeGreaterThan(0); + + const financeSession = await adminLoginWithAccount(finance.username, finance.password); + const financeAdmin = await adminCtxOf(financeSession.accessToken); + + const me = await financeAdmin.get('/api/v1/admin/auth/me'); + expect(me.ok()).toBeTruthy(); + const meBody = (await me.json()) as any; + expect(meBody.data.admin.account_kind).toBe('site_finance'); + + const confirm = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/confirm`); + expect(confirm.ok(), `site_finance confirm status=${confirm.status()}`).toBeTruthy(); + expect((await confirm.json()).code).toBe(0); + + const pay = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/payments`, { + data: { amount: unpaid, method: 'e2e_finance', remark: 'site finance payment' }, + }); + expect(pay.ok(), `site_finance payment status=${pay.status()}`).toBeTruthy(); + expect((await pay.json()).data.bill.status).toBe('settled'); + + await financeAdmin.dispose(); + + const credit2 = await setupCreditPlayer({ + credit_limit: 100_000, + username: `e2e_perm2_${Date.now()}`, + }); + const periodId2 = await openSettlementPeriod(superAdmin, credit2.admin_site_id); + const drawNo2 = await fetchOpenDrawNo(); + const player2 = await playerLoginViaBypass({ + site_code: credit2.site_code, + username: credit2.username, + password: credit2.password, + }); + + await runWalletDrawSettlement({ + adminToken: superSession.accessToken, + playerToken: player2.access_token, + drawNo: drawNo2, + betNumber: `4${String(Date.now()).slice(-3)}`, + betAmount: 10_000, + expectWalletIncrease: false, + }); + + const close2 = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId2}/close`); + expect(close2.ok()).toBeTruthy(); + + const bills2 = await superAdmin.get( + `/api/v1/admin/settlement-bills?settlement_period_id=${periodId2}&size=20`, + ); + const bill2 = ((await bills2.json()) as any).data.items.find( + (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit2.player_id, + ); + expect(bill2).toBeTruthy(); + + await superAdmin.post(`/api/v1/admin/settlement-bills/${bill2.id}/confirm`); + + const agentSession = await adminLoginWithAccount('e2e_leaf_agent', DEFAULT_PASS); + const agentAdmin = await adminCtxOf(agentSession.accessToken); + const agentMe = (await (await agentAdmin.get('/api/v1/admin/auth/me')).json()) as any; + expect(agentMe.data.admin.account_kind).toBe('agent_operator'); + + const badDebt = await agentAdmin.post( + `/api/v1/admin/settlement-bills/${bill2.id}/bad-debt-write-off`, + { data: { reason: 'e2e should deny' } }, + ); + expect([403, 404]).toContain(badDebt.status()); + + const financeBadDebt = await setupSiteOperator({ + username: `e2e_fin_bd_${Date.now()}`, + password: DEFAULT_PASS, + role_slug: 'site_finance', + }); + const finance2 = await adminLoginWithAccount(financeBadDebt.username, DEFAULT_PASS); + const finance2Ctx = await adminCtxOf(finance2.accessToken); + const writeOff = await finance2Ctx.post( + `/api/v1/admin/settlement-bills/${bill2.id}/bad-debt-write-off`, + { data: { reason: 'e2e uncollectible' } }, + ); + expect(writeOff.ok(), `site_finance bad debt status=${writeOff.status()}`).toBeTruthy(); + expect((await writeOff.json()).code).toBe(0); + + await agentAdmin.dispose(); + await finance2Ctx.dispose(); + await superAdmin.dispose(); +}); diff --git a/e2e/tests/api/19.settlement-bad-debt-partial.spec.ts b/e2e/tests/api/19.settlement-bad-debt-partial.spec.ts new file mode 100644 index 0000000..5ed2710 --- /dev/null +++ b/e2e/tests/api/19.settlement-bad-debt-partial.spec.ts @@ -0,0 +1,105 @@ +/** + * E2E:部分收付后坏账核销 → settlement_confirm 累计释额至账单全额。 + */ + +import { test, expect } from '@playwright/test'; +import { + adminLoginViaBypass, + adminCtxOf, + adminLoginWithAccount, + fetchBillCreditLedgerRows, + fetchOpenDrawNo, + openSettlementPeriod, + playerLoginViaBypass, + setupCreditPlayer, + setupSiteOperator, +} from './_helper'; +import { runWalletDrawSettlement } from './helpers/draw-settlement'; + +const DEFAULT_PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678'; + +test('部分收付后坏账核销 → credit_ledger 累计至全额', async () => { + test.setTimeout(360_000); + + const credit = await setupCreditPlayer({ + credit_limit: 100_000, + username: `e2e_bd_partial_${Date.now()}`, + }); + const finance = await setupSiteOperator({ + username: `e2e_fin_bd_${Date.now()}`, + password: DEFAULT_PASS, + role_slug: 'site_finance', + }); + const drawNo = await fetchOpenDrawNo(); + + const superSession = await adminLoginViaBypass(); + const superAdmin = await adminCtxOf(superSession.accessToken); + const periodId = await openSettlementPeriod(superAdmin, credit.admin_site_id); + + const playerSession = await playerLoginViaBypass({ + site_code: credit.site_code, + username: credit.username, + password: credit.password, + }); + + await runWalletDrawSettlement({ + adminToken: superSession.accessToken, + playerToken: playerSession.access_token, + drawNo, + betNumber: '4321', + publishWinNumber: '9999', + betAmount: 10_000, + expectWalletIncrease: false, + }); + + const close = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); + expect(close.ok()).toBeTruthy(); + + const bills = await superAdmin.get( + `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, + ); + const billsBody = (await bills.json()) as any; + const playerBill = billsBody.data.items.find( + (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, + ); + expect(playerBill).toBeTruthy(); + expect(Number(playerBill.net_amount)).toBeGreaterThan(0); + + const billId = Number(playerBill.id); + await superAdmin.post(`/api/v1/admin/settlement-bills/${billId}/confirm`); + + const billDetail = (await (await superAdmin.get(`/api/v1/admin/settlement-bills/${billId}`)).json()) as any; + const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount)); + const firstPay = Math.floor(unpaid / 2); + expect(firstPay).toBeGreaterThan(0); + + const financeSession = await adminLoginWithAccount(finance.username, finance.password); + const financeAdmin = await adminCtxOf(financeSession.accessToken); + + const pay1 = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/payments`, { + data: { amount: firstPay, method: 'e2e_bd_partial', remark: 'partial before write-off' }, + }); + expect(pay1.ok()).toBeTruthy(); + expect((await pay1.json()).data.bill.status).toBe('partial_paid'); + + const ledgerAfterPay1 = await fetchBillCreditLedgerRows(credit.player_id, billId, 'settlement_confirm'); + expect(ledgerAfterPay1).toHaveLength(1); + expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay); + + const writeOff = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/bad-debt-write-off`, { + data: { reason: 'e2e partial then bad debt' }, + }); + expect(writeOff.ok(), `bad debt status=${writeOff.status()}`).toBeTruthy(); + expect((await writeOff.json()).code).toBe(0); + + const settledBill = (await (await superAdmin.get(`/api/v1/admin/settlement-bills/${billId}`)).json()) as any; + expect(settledBill.data.bill.status).toBe('settled'); + expect(Number(settledBill.data.bill.unpaid_amount)).toBe(0); + + const ledgerAfterWriteOff = await fetchBillCreditLedgerRows(credit.player_id, billId, 'settlement_confirm'); + expect(ledgerAfterWriteOff).toHaveLength(1); + expect(Number(ledgerAfterWriteOff[0].amount)).toBe(unpaid); + + await financeAdmin.dispose(); + await superAdmin.dispose(); +}); diff --git a/e2e/tests/api/_helper.ts b/e2e/tests/api/_helper.ts index 1c03619..23bf95b 100644 --- a/e2e/tests/api/_helper.ts +++ b/e2e/tests/api/_helper.ts @@ -144,6 +144,93 @@ export async function setupCreditPlayer(opts?: { credit_limit?: number; username return data.data; } +export async function setupSiteOperator(opts?: { + role_slug?: string; + username?: string; + password?: string; +}): Promise<{ + admin_user_id: number; + username: string; + password: string; + role_slug: string; + admin_site_id: number; + site_code: string; +}> { + const data = await e2e('POST', '/site-operator/setup', opts ?? {}); + return data.data; +} + +export async function adminLoginWithAccount( + account: string, + password: string, +): Promise> { + const captcha = await fetchAdminCaptcha(); + const ctx = await pwRequest.newContext(); + const resp = await ctx.post('/api/v1/admin/auth/login', { + data: { + account, + password, + captcha_key: captcha.captcha_key, + captcha_code: 'LOTTERY_E2E_BYPASS', + }, + }); + expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy(); + const body = (await resp.json()) as { data: any }; + await ctx.dispose(); + if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body)); + return { + accessToken: body.data.token, + tokenType: body.data.token_type ?? 'Bearer', + expiresIn: 0, + admin: body.data.admin, + }; +} + +/** 以当前时间为中心的 2h 账期窗口;开账前会清掉同站历史账期避免重叠。 */ +export function periodWindowIso(): { start: string; end: string } { + const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19); + const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19); + return { start, end }; +} + +export async function resetSiteSettlement(siteCode?: string): Promise { + await e2e('POST', '/settlement/reset', { site_code: siteCode ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo' }); +} + +export async function fetchBillCreditLedgerRows( + playerId: number, + billId: number, + reason: 'settlement_confirm' | 'settlement_payout', +): Promise> { + const ledger = await e2e('GET', `/inspect/credit-ledger?player_id=${playerId}&limit=50`); + return (ledger.data.rows as any[]).filter( + (row) => + row.reason === reason && + row.ref_type === 'settlement_bill' && + Number(row.ref_id) === billId, + ); +} + +export async function openSettlementPeriod( + admin: APIRequestContext, + adminSiteId: number, + siteCode?: string, +): Promise { + await resetSiteSettlement(siteCode); + const { start, end } = periodWindowIso(); + const open = await admin.post('/api/v1/admin/settlement-periods', { + data: { + admin_site_id: adminSiteId, + period_start: start, + period_end: end, + }, + }); + expect(open.ok(), `open period status=${open.status()}`).toBeTruthy(); + const openBody = (await open.json()) as any; + expect(openBody.code).toBe(0); + return Number(openBody.data.id); +} + export async function mintSsoJwt(sitePlayerId?: string): Promise<{ jwt: string; site_player_id: string }> { const data = await e2e('POST', '/sso/mint-jwt', { site_player_id: sitePlayerId ?? `e2e-sso-${Date.now()}`, diff --git a/e2e/tests/api/helpers/draw-settlement.ts b/e2e/tests/api/helpers/draw-settlement.ts index 30d634e..9a051e9 100644 --- a/e2e/tests/api/helpers/draw-settlement.ts +++ b/e2e/tests/api/helpers/draw-settlement.ts @@ -27,12 +27,15 @@ export async function runWalletDrawSettlement(params: { playerToken: string; drawNo: string; betNumber?: string; + /** 公布头奖号码;默认与 betNumber 相同(必中)。传不同值可测玩家输单。 */ + publishWinNumber?: string; betAmount?: number; balanceBefore?: number; expectWalletIncrease?: boolean; }): Promise { const drawNo = params.drawNo; const betNumber = params.betNumber ?? '1234'; + const publishWinNumber = params.publishWinNumber ?? betNumber; const betAmount = params.betAmount ?? 10_000; const player = await pwPlayerCtx(params.playerToken); @@ -63,7 +66,7 @@ export async function runWalletDrawSettlement(params: { const drawId = await resolveDrawId(admin, drawNo); const store = await admin.post(`/api/v1/admin/draws/${drawId}/result-batches`, { - data: { items: buildAllResultItems(betNumber) }, + data: { items: buildAllResultItems(publishWinNumber) }, }); expect(store.ok(), `store batch status=${store.status()}`).toBeTruthy(); const storeBody = (await store.json()) as any; diff --git a/e2e/tests/ui/admin-settlement-center.spec.ts b/e2e/tests/ui/admin-settlement-center.spec.ts new file mode 100644 index 0000000..bc61e8b --- /dev/null +++ b/e2e/tests/ui/admin-settlement-center.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +const ADMIN_URL = process.env.PLAYWRIGHT_ADMIN_URL ?? 'http://localhost:3801'; +const ADMIN_ACCOUNT = process.env.E2E_ADMIN_USERNAME ?? 'admin'; +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '12345678'; + +test('超管进入结算中心 → 账期管理页可见', async ({ page }) => { + test.setTimeout(120_000); + + await page.goto(`${ADMIN_URL}/admin/login`); + const account = page.locator('#admin-account'); + await account.waitFor({ state: 'visible', timeout: 60_000 }); + await account.fill(ADMIN_ACCOUNT); + await page.locator('#admin-password').fill(ADMIN_PASSWORD); + await page.locator('img[src^="data:image"]').waitFor({ state: 'visible', timeout: 30_000 }); + await page.locator('#admin-captcha').fill('LOTTERY_E2E_BYPASS'); + await page.getByRole('button', { name: /^登录$|Sign in|submit/i }).click(); + await page.waitForURL(/\/admin(?!\/login)/, { timeout: 45_000 }); + + await page.goto(`${ADMIN_URL}/admin/settlement-center`); + await expect(page.getByText(/结算中心|Settlement center/i).first()).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: /开账|Open period/i }).first()).toBeVisible({ + timeout: 30_000, + }); +}); diff --git a/e2e/tests/ui/front-place-bet.spec.ts b/e2e/tests/ui/front-place-bet.spec.ts new file mode 100644 index 0000000..22a0fb3 --- /dev/null +++ b/e2e/tests/ui/front-place-bet.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { fetchOpenDrawNo, tickDraws } from '../api/_helper'; + +const FRONT_URL = process.env.PLAYWRIGHT_FRONT_URL ?? 'http://localhost:3800'; +const USER = process.env.E2E_PLAYER_USERNAME ?? 'demo_player'; +const PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678'; + +async function loginToHall(page: import('@playwright/test').Page): Promise { + await page.goto(`${FRONT_URL}/login`); + const userInput = page.locator('#login-user'); + await userInput.waitFor({ state: 'visible', timeout: 60_000 }); + await userInput.fill(USER); + await page.locator('#login-pass').fill(PASS); + await page.locator('img[src^="data:image"]').waitFor({ state: 'visible', timeout: 30_000 }); + await page.locator('#login-captcha').fill('LOTTERY_E2E_BYPASS'); + await page.getByRole('button', { name: /^登录$|Sign in|submit/i }).click(); + await page.waitForURL(/\/hall/, { timeout: 45_000 }); +} + +test('玩家端大厅 → 填写号码金额 → 预览并提交下注', async ({ page }) => { + test.setTimeout(180_000); + + await fetchOpenDrawNo(); + await tickDraws(); + + await loginToHall(page); + + const grid = page.getByRole('section', { name: /下注表格|Betting/i }); + await grid.waitFor({ state: 'visible', timeout: 60_000 }); + + const numberInput = grid.locator('input[inputmode="text"]').first(); + await numberInput.waitFor({ state: 'visible', timeout: 30_000 }); + await numberInput.fill('12'); + + const amountInput = grid.locator('input[inputmode="decimal"]').first(); + await amountInput.fill('10'); + + const submitBtn = page.getByRole('button', { name: /提交下注|Submit bet/i }); + await expect(submitBtn).toBeEnabled({ timeout: 60_000 }); + await submitBtn.click(); + + const confirmBtn = page.getByRole('button', { name: /确认提交|Confirm/i }); + await confirmBtn.waitFor({ state: 'visible', timeout: 30_000 }); + await confirmBtn.click(); + + await expect(page.getByText(/下注成功|Bet placed|订单号|Order/i)).toBeVisible({ + timeout: 45_000, + }); +}); diff --git a/tests/Feature/SettlementPartialPaymentCreditTest.php b/tests/Feature/SettlementPartialPaymentCreditTest.php new file mode 100644 index 0000000..e486620 --- /dev/null +++ b/tests/Feature/SettlementPartialPaymentCreditTest.php @@ -0,0 +1,198 @@ +where('is_default', true)->first(); + $agentId = (int) DB::table('agent_nodes') + ->where('admin_site_id', (int) $site->id) + ->where('depth', 0) + ->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'site_player_id' => 'partial-'.uniqid('', true), + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'partial_'.uniqid('', true), + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $agentId, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 100_000, + 'used_credit' => $initialUsedCreditMajor, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $unpaid = abs($netAmount); + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $agentId, + 'net_amount' => $netAmount, + 'unpaid_amount' => $unpaid, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'partial_pay_'.uniqid('', true), + 'name' => 'PartialPay', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return [ + 'player' => $player, + 'billId' => $billId, + 'admin' => $admin, + 'netAmount' => $netAmount, + ]; +} + +function settlementBillLedgerRows(Player $player, int $billId, string $reason): \Illuminate\Support\Collection +{ + return DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $player->id) + ->where('reason', $reason) + ->where('ref_type', 'settlement_bill') + ->where('ref_id', $billId) + ->get(); +} + +test('partial payments accumulate settlement_confirm ledger and release used credit incrementally', function (): void { + $fixture = partialPaymentCreditFixture(600, 10); + $player = $fixture['player']; + $billId = $fixture['billId']; + $adminId = (int) $fixture['admin']->id; + $usedBefore = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + + $service = app(SettlementPaymentService::class); + $service->recordPayment($billId, 200, $adminId); + + $rows = settlementBillLedgerRows($player, $billId, 'settlement_confirm'); + expect($rows)->toHaveCount(1) + ->and((int) $rows[0]->amount)->toBe(200); + + $usedAfterFirst = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + expect($usedAfterFirst)->toBe($usedBefore - CreditAmountScale::minorToMajor(200, 'NPR')); + + $service->recordPayment($billId, 400, $adminId); + + $rows = settlementBillLedgerRows($player, $billId, 'settlement_confirm'); + expect($rows)->toHaveCount(1) + ->and((int) $rows[0]->amount)->toBe(600); + + $usedAfterSecond = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + expect($usedAfterSecond)->toBe($usedBefore - CreditAmountScale::minorToMajor(600, 'NPR')); + + expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled'); +}); + +test('partial payments accumulate settlement_payout ledger for player win bill', function (): void { + $fixture = partialPaymentCreditFixture(-800); + $player = $fixture['player']; + $billId = $fixture['billId']; + $adminId = (int) $fixture['admin']->id; + $usedBefore = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + + $service = app(SettlementPaymentService::class); + $service->recordPayment($billId, 300, $adminId); + + $rows = settlementBillLedgerRows($player, $billId, 'settlement_payout'); + expect($rows)->toHaveCount(1) + ->and((int) $rows[0]->amount)->toBe(300); + + $service->recordPayment($billId, 500, $adminId); + + $rows = settlementBillLedgerRows($player, $billId, 'settlement_payout'); + expect($rows)->toHaveCount(1) + ->and((int) $rows[0]->amount)->toBe(800); + + $usedAfter = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + expect($usedAfter)->toBe($usedBefore); + + expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled'); +}); + +test('bad debt after partial payment completes cumulative settlement_confirm ledger', function (): void { + $fixture = partialPaymentCreditFixture(800, 10); + $player = $fixture['player']; + $billId = $fixture['billId']; + $adminId = (int) $fixture['admin']->id; + $usedBefore = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + + $service = app(SettlementPaymentService::class); + $service->recordPayment($billId, 300, $adminId); + + $usedAfterPartial = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + expect($usedAfterPartial)->toBe($usedBefore - CreditAmountScale::minorToMajor(300, 'NPR')); + expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('partial_paid'); + + app(AgentSettlementBadDebtService::class)->writeOff($billId, 'uncollectible', $adminId); + + $rows = settlementBillLedgerRows($player, $billId, 'settlement_confirm'); + expect($rows)->toHaveCount(1) + ->and((int) $rows[0]->amount)->toBe(800); + + $usedAfterBadDebt = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + expect($usedAfterBadDebt)->toBe($usedBefore - CreditAmountScale::minorToMajor(800, 'NPR')); + + expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled'); + expect((int) DB::table('settlement_bills')->where('id', $billId)->value('unpaid_amount'))->toBe(0); +}); + +test('repeated payment after settled bill does not alter credit ledger', function (): void { + $fixture = partialPaymentCreditFixture(500, 8); + $player = $fixture['player']; + $billId = $fixture['billId']; + $adminId = (int) $fixture['admin']->id; + + $service = app(SettlementPaymentService::class); + $service->recordPayment($billId, 500, $adminId); + + $usedAfterSettled = (int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'); + expect(settlementBillLedgerRows($player, $billId, 'settlement_confirm'))->toHaveCount(1); + + expect(fn () => $service->recordPayment($billId, 1, $adminId)) + ->toThrow(\Illuminate\Validation\ValidationException::class); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit')) + ->toBe($usedAfterSettled); + expect((int) settlementBillLedgerRows($player, $billId, 'settlement_confirm')[0]->amount)->toBe(500); +});