From c8c90e3e941df5beef66f9394d6dccdfb20cac66 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 26 May 2026 14:58:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=A5=96=E6=B1=A0?= =?UTF-8?q?=E4=B8=8E=E9=92=B1=E5=8C=85=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 AdminJackpotPoolUpdateController 校验规则,禁止传入 current_amount。 优化 AdminRiskPoolManualStatusController:更新奖池状态后同步 Redis 状态。 在 TransferOrderReconcileController 中新增 completeCredit 方法,用于处理卡住的转账订单对账。 调整 TransferOrderListController:优化转账订单处理条件。 在 TicketItemsIndexController 中实现支持时区的日期筛选,提升日期处理准确性。 扩展 JackpotPool 模型,新增 adjustments 关联关系。 改进票据与钱包相关服务中的错误处理和事务管理。 --- .../AdminJackpotPoolAdjustController.php | 69 +++++ ...inJackpotPoolAdjustmentIndexController.php | 43 ++++ .../AdminJackpotPoolUpdateController.php | 2 +- .../PresentsJackpotPoolAdjustment.php | 25 ++ .../AdminRiskPoolManualStatusController.php | 11 +- .../Wallet/TransferOrderListController.php | 9 +- .../TransferOrderReconcileController.php | 26 ++ .../V1/Ticket/TicketDrawMyMatchController.php | 6 +- .../V1/Ticket/TicketItemShowController.php | 13 +- .../V1/Ticket/TicketItemsIndexController.php | 27 +- .../Api/V1/Wallet/WalletLogsController.php | 9 +- .../Jackpot/AdminJackpotPoolAdjustRequest.php | 22 ++ .../TransferOrderCompleteCreditRequest.php | 20 ++ app/Models/JackpotPool.php | 5 + app/Models/JackpotPoolAdjustment.php | 41 +++ app/Services/Draw/DrawAdminActionService.php | 6 + .../Draw/DrawCancelBetRefundService.php | 91 +++++++ app/Services/Draw/DrawHallSnapshotBuilder.php | 8 + app/Services/Draw/DrawManualResultService.php | 7 + app/Services/Draw/DrawPublishService.php | 22 +- .../Jackpot/JackpotContributionService.php | 8 + .../Jackpot/JackpotPoolAdjustmentService.php | 95 +++++++ .../SettlementBatchWorkflowService.php | 10 +- .../Settlement/SettlementOrchestrator.php | 1 + app/Services/Ticket/RiskPoolService.php | 30 +++ .../TicketPendingConfirmReconcileService.php | 156 +++++++---- .../Ticket/TicketPlacementService.php | 26 +- app/Services/Ticket/TicketPreviewService.php | 10 +- app/Services/Ticket/TicketWalletService.php | 15 +- .../Wallet/LotteryTransferService.php | 155 +++++++++-- app/Support/AdminAuthorizationRegistry.php | 3 + ...icket_item_id_to_jackpot_contributions.php | 41 +++ ..._create_jackpot_pool_adjustments_table.php | 30 +++ ..._jackpot_pool_adjustment_api_resources.php | 105 ++++++++ lang/en/jackpot.php | 7 + lang/ne/jackpot.php | 7 + lang/zh/jackpot.php | 7 + routes/api/v1/admin/jackpot.php | 6 + routes/api/v1/admin/wallet.php | 2 + .../Feature/AdminBusinessLogicGuardsTest.php | 243 ++++++++++++++++++ .../AdminJackpotPoolAdjustmentTest.php | 110 ++++++++ tests/Feature/AdminWalletApiTest.php | 51 ++++ tests/Feature/DrawPipelineTest.php | 132 ++++++++++ tests/Feature/TicketBettingApiTest.php | 227 ++++++++++++++++ tests/Feature/WalletTransferTest.php | 42 +++ 45 files changed, 1877 insertions(+), 104 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustmentIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Jackpot/Concerns/PresentsJackpotPoolAdjustment.php create mode 100644 app/Http/Requests/Admin/Jackpot/AdminJackpotPoolAdjustRequest.php create mode 100644 app/Http/Requests/Admin/Wallet/TransferOrderCompleteCreditRequest.php create mode 100644 app/Models/JackpotPoolAdjustment.php create mode 100644 app/Services/Draw/DrawCancelBetRefundService.php create mode 100644 app/Services/Jackpot/JackpotPoolAdjustmentService.php create mode 100644 database/migrations/2026_05_29_100000_add_unique_ticket_item_id_to_jackpot_contributions.php create mode 100644 database/migrations/2026_05_30_100000_create_jackpot_pool_adjustments_table.php create mode 100644 database/migrations/2026_05_30_100001_add_jackpot_pool_adjustment_api_resources.php create mode 100644 lang/en/jackpot.php create mode 100644 lang/ne/jackpot.php create mode 100644 lang/zh/jackpot.php create mode 100644 tests/Feature/AdminBusinessLogicGuardsTest.php create mode 100644 tests/Feature/AdminJackpotPoolAdjustmentTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustController.php new file mode 100644 index 0000000..63833fd --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustController.php @@ -0,0 +1,69 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + try { + $row = $this->adjustments->apply( + $pool, + $admin, + (int) $request->validated('amount_delta'), + (string) $request->validated('reason'), + $request, + ); + } catch (\RuntimeException $e) { + $msg = match ($e->getMessage()) { + 'adjustment_delta_zero' => trans('jackpot.adjustment_delta_zero', [], $request->lotteryLocale()), + 'adjustment_reason_required' => trans('jackpot.adjustment_reason_required', [], $request->lotteryLocale()), + 'adjustment_would_make_balance_negative' => trans('jackpot.adjustment_negative_balance', [], $request->lotteryLocale()), + default => trans('api.client_error', [], $request->lotteryLocale()), + }; + + return ApiResponse::error($msg, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 422); + } + + $pool->refresh(); + + return ApiResponse::success([ + 'adjustment' => $this->adjustmentRow($row), + 'pool' => [ + 'id' => (int) $pool->id, + 'currency_code' => $pool->currency_code, + 'current_amount' => (int) $pool->current_amount, + 'updated_at' => $pool->updated_at?->toIso8601String(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustmentIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustmentIndexController.php new file mode 100644 index 0000000..f20770f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolAdjustmentIndexController.php @@ -0,0 +1,43 @@ +perPage($request, 'per_page', 10, 100); + $page = $this->page($request); + + $paginator = JackpotPoolAdjustment::query() + ->where('jackpot_pool_id', $pool->id) + ->with('adminUser:id,username,name') + ->orderByDesc('id') + ->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => $paginator->getCollection() + ->map(fn (JackpotPoolAdjustment $row) => $this->adjustmentRow($row)) + ->values() + ->all(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php index e167ac8..34b5976 100644 --- a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php @@ -16,7 +16,7 @@ final class AdminJackpotPoolUpdateController extends Controller public function __invoke(Request $request, JackpotPool $pool): JsonResponse { $data = $request->validate([ - 'current_amount' => 'sometimes|integer|min:0', + 'current_amount' => 'prohibited', 'contribution_rate' => 'sometimes|numeric|min:0|max:1', 'trigger_threshold' => 'sometimes|integer|min:0', 'payout_rate' => 'sometimes|numeric|min:0|max:1', diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/Concerns/PresentsJackpotPoolAdjustment.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/Concerns/PresentsJackpotPoolAdjustment.php new file mode 100644 index 0000000..9dbbf05 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/Concerns/PresentsJackpotPoolAdjustment.php @@ -0,0 +1,25 @@ + */ + private function adjustmentRow(JackpotPoolAdjustment $row): array + { + return [ + 'id' => (int) $row->id, + 'adjustment_no' => $row->adjustment_no, + 'jackpot_pool_id' => (int) $row->jackpot_pool_id, + 'admin_user_id' => (int) $row->admin_user_id, + 'admin_username' => $row->adminUser?->username, + 'amount_delta' => (int) $row->amount_delta, + 'balance_before' => (int) $row->balance_before, + 'balance_after' => (int) $row->balance_after, + 'reason' => $row->reason, + 'created_at' => $row->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php index 60fb903..f179323 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php @@ -10,9 +10,13 @@ use App\Models\RiskPoolLockLog; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Services\Ticket\RiskPoolService; final class AdminRiskPoolManualStatusController extends Controller { + public function __construct( + private readonly RiskPoolService $riskPoolService, + ) {} public function close(Draw $draw, string $number_4d): JsonResponse { $pool = $this->updateStatus($draw, $number_4d, true, 'close', 'admin_manual_close'); @@ -79,7 +83,12 @@ final class AdminRiskPoolManualStatusController extends Controller ]); } - return $pool->fresh(); + $fresh = $pool->fresh(); + if ($fresh !== null) { + $this->riskPoolService->syncRedisStateFromPool($fresh); + } + + return $fresh; }); } diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index 05f3f81..f1da415 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -122,7 +122,14 @@ final class TransferOrderListController extends Controller 'idempotent_key' => $o->idempotent_key, 'status' => $o->status, 'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile', - 'can_manually_process' => $canWriteWallet && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true), + 'can_complete_credit' => $canWriteWallet + && $o->direction === 'in' + && $o->status === 'pending_reconcile' + && $o->fail_reason === 'lottery_credit_failed' + && trim((string) $o->external_ref_no) !== '', + 'can_manually_process' => $canWriteWallet + && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true) + && ! ($o->direction === 'out' && $o->status === 'pending_reconcile'), 'external_ref_no' => $o->external_ref_no, 'external_request_payload' => $o->external_request_payload, 'external_response_payload' => $o->external_response_payload, diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php index 746aa33..0be1496 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php @@ -10,6 +10,7 @@ use App\Services\Wallet\LotteryTransferService; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Wallet\TransferOrderReverseRequest; use App\Http\Requests\Admin\Wallet\TransferOrderManuallyProcessRequest; +use App\Http\Requests\Admin\Wallet\TransferOrderCompleteCreditRequest; use Illuminate\Http\JsonResponse; /** @@ -71,4 +72,29 @@ final class TransferOrderReconcileController extends Controller return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'manually_processed']); } + + public function completeCredit(TransferOrderCompleteCreditRequest $request, string $transferNo): JsonResponse + { + $order = TransferOrder::query()->where('transfer_no', $transferNo)->first(); + if ($order === null) { + return ApiResponse::error(__('wallet.order_not_found'), 404); + } + + try { + $this->transferService->reconcileTransferOrder( + $order, + 'complete_credit', + (string) $request->validated('remark', ''), + ); + } catch (WalletOperationException $e) { + return ApiResponse::error( + LotteryMessage::wallet($request, $e->lotteryCode), + $e->lotteryCode, + null, + $e->httpStatus, + ); + } + + return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'success']); + } } diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php b/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php index a470d0a..0aea591 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php @@ -61,14 +61,14 @@ final class TicketDrawMyMatchController extends Controller $itemIds = TicketItem::query() ->where('draw_id', $draw->id) ->where('player_id', $player->id) - ->whereIn('status', ['pending_draw', 'settled_win', 'settled_lose']) + ->whereIn('status', ['pending_draw', 'pending_payout', 'settled_win', 'settled_lose']) ->pluck('id'); $hasBets = $itemIds->isNotEmpty(); $winningItemIds = TicketItem::query() ->where('draw_id', $draw->id) ->where('player_id', $player->id) - ->where('status', 'settled_win') + ->whereIn('status', ['settled_win', 'pending_payout']) ->where(function ($q): void { $q->where('win_amount', '>', 0) ->orWhere('jackpot_win_amount', '>', 0); @@ -90,7 +90,7 @@ final class TicketDrawMyMatchController extends Controller $sums = TicketItem::query() ->where('draw_id', $draw->id) ->where('player_id', $player->id) - ->whereIn('status', ['settled_win', 'settled_lose']) + ->whereIn('status', ['settled_win', 'settled_lose', 'pending_payout']) ->selectRaw('coalesce(sum(win_amount),0) as sum_win, coalesce(sum(jackpot_win_amount),0) as sum_jackpot') ->first(); diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php index a567677..51724a5 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php @@ -62,12 +62,13 @@ final class TicketItemShowController extends Controller ->where('biz_no', $order?->order_no) ->orderByDesc('id') ->first(); - $payoutTxn = WalletTxn::query() - ->where('player_id', $player->id) - ->where('biz_type', 'settle_payout') - ->where('biz_no', 'like', 'SB%') - ->orderByDesc('id') - ->first(); + $payoutTxn = $settlementBatch !== null + ? WalletTxn::query() + ->where('player_id', $player->id) + ->where('biz_type', 'settle_payout') + ->where('biz_no', 'SB'.$settlementBatch->id) + ->first() + : null; $timeline = []; if ($order?->created_at !== null) { diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index e453f26..3dee03a 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -2,9 +2,9 @@ namespace App\Http\Controllers\Api\V1\Ticket; +use Carbon\Carbon; use App\Models\Player; use App\Models\TicketItem; -use App\Models\WalletTxn; use App\Support\ApiResponse; use Illuminate\Http\Request; use App\Support\PaginationTrait; @@ -65,11 +65,13 @@ final class TicketItemsIndexController extends Controller } if ($startDate !== null) { - $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate)); + $fromUtc = $this->scheduleDateStartUtc($startDate); + $query->whereHas('order', fn ($q) => $q->where('created_at', '>=', $fromUtc)); } if ($endDate !== null) { - $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate)); + $toUtc = $this->scheduleDateEndUtc($endDate); + $query->whereHas('order', fn ($q) => $q->where('created_at', '<=', $toUtc)); } $paginator = $query->paginate(perPage: $perPage, page: $page); @@ -119,4 +121,23 @@ final class TicketItemsIndexController extends Controller return $value; } + + private function scheduleTimezone(): string + { + return (string) config('lottery.draw.timezone', 'UTC'); + } + + private function scheduleDateStartUtc(string $ymd): Carbon + { + return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone()) + ->startOfDay() + ->utc(); + } + + private function scheduleDateEndUtc(string $ymd): Carbon + { + return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone()) + ->endOfDay() + ->utc(); + } } diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php index acea268..565474a 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php @@ -27,8 +27,8 @@ final class WalletLogsController extends Controller 'transfer_out' => ['transfer_out'], 'refund' => ['transfer_out_refund'], 'reversal' => ['reversal'], - 'bet' => ['bet_deduct', 'bet'], - 'prize' => ['settle_payout', 'prize'], + 'bet' => ['bet_deduct', 'bet', 'bet_reverse'], + 'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'], ]; public function __invoke(Request $request): JsonResponse @@ -153,8 +153,9 @@ final class WalletLogsController extends Controller { return match ($biz) { 'transfer_out_refund' => 'refund', - 'bet_deduct' => 'bet', - 'settle_payout' => 'prize', + 'bet_deduct', 'bet' => 'bet', + 'bet_reverse' => 'reversal', + 'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize', 'reversal' => 'reversal', default => $biz, }; diff --git a/app/Http/Requests/Admin/Jackpot/AdminJackpotPoolAdjustRequest.php b/app/Http/Requests/Admin/Jackpot/AdminJackpotPoolAdjustRequest.php new file mode 100644 index 0000000..250c356 --- /dev/null +++ b/app/Http/Requests/Admin/Jackpot/AdminJackpotPoolAdjustRequest.php @@ -0,0 +1,22 @@ + */ + public function rules(): array + { + return [ + 'amount_delta' => ['required', 'integer', 'not_in:0'], + 'reason' => ['required', 'string', 'min:3', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Wallet/TransferOrderCompleteCreditRequest.php b/app/Http/Requests/Admin/Wallet/TransferOrderCompleteCreditRequest.php new file mode 100644 index 0000000..086c576 --- /dev/null +++ b/app/Http/Requests/Admin/Wallet/TransferOrderCompleteCreditRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Models/JackpotPool.php b/app/Models/JackpotPool.php index df0be2d..1640243 100644 --- a/app/Models/JackpotPool.php +++ b/app/Models/JackpotPool.php @@ -46,4 +46,9 @@ final class JackpotPool extends Model { return $this->hasMany(JackpotContribution::class, 'jackpot_pool_id'); } + + public function adjustments(): HasMany + { + return $this->hasMany(JackpotPoolAdjustment::class, 'jackpot_pool_id'); + } } diff --git a/app/Models/JackpotPoolAdjustment.php b/app/Models/JackpotPoolAdjustment.php new file mode 100644 index 0000000..b6045a8 --- /dev/null +++ b/app/Models/JackpotPoolAdjustment.php @@ -0,0 +1,41 @@ + 'integer', + 'balance_before' => 'integer', + 'balance_after' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function pool(): BelongsTo + { + return $this->belongsTo(JackpotPool::class, 'jackpot_pool_id'); + } + + /** @return BelongsTo */ + public function adminUser(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'admin_user_id'); + } +} diff --git a/app/Services/Draw/DrawAdminActionService.php b/app/Services/Draw/DrawAdminActionService.php index 2dfba37..58b38d3 100644 --- a/app/Services/Draw/DrawAdminActionService.php +++ b/app/Services/Draw/DrawAdminActionService.php @@ -8,6 +8,10 @@ use Illuminate\Support\Facades\DB; final class DrawAdminActionService { + public function __construct( + private readonly DrawCancelBetRefundService $cancelBetRefund, + ) {} + public function manualClose(Draw $draw): Draw { return DB::transaction(function () use ($draw): Draw { @@ -43,6 +47,8 @@ final class DrawAdminActionService throw new \RuntimeException('draw_result_exists'); } + $this->cancelBetRefund->refundOpenBetsForDraw($locked); + $locked->forceFill(['status' => DrawStatus::Cancelled->value])->save(); return $locked->refresh(); diff --git a/app/Services/Draw/DrawCancelBetRefundService.php b/app/Services/Draw/DrawCancelBetRefundService.php new file mode 100644 index 0000000..9de4002 --- /dev/null +++ b/app/Services/Draw/DrawCancelBetRefundService.php @@ -0,0 +1,91 @@ +where('draw_id', $draw->id) + ->whereIn('status', ['settled_win', 'settled_lose', 'pending_payout']) + ->exists(); + + if ($hasBlockedItems) { + throw new \RuntimeException('draw_has_settled_tickets'); + } + + $orders = TicketOrder::query() + ->where('draw_id', $draw->id) + ->whereNotIn('status', ['refunded', 'failed']) + ->orderBy('id') + ->get(); + + foreach ($orders as $order) { + $this->refundOrder($draw, $order); + } + } + + private function refundOrder(Draw $draw, TicketOrder $order): void + { + $lockedOrder = TicketOrder::query()->whereKey($order->id)->lockForUpdate()->first(); + if ($lockedOrder === null || in_array($lockedOrder->status, ['refunded', 'failed'], true)) { + return; + } + + $items = TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->whereIn('status', ['pending_confirm', 'pending_draw', 'pending_payout']) + ->with('combinations') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $locks = []; + foreach ($item->combinations as $combo) { + $locks[] = [ + 'number_4d' => (string) $combo->number_4d, + 'amount' => (int) $combo->estimated_payout, + ]; + } + + if ($locks !== []) { + $this->riskPool->release((int) $draw->id, $item, $locks); + } + + $item->forceFill([ + 'status' => 'refunded', + 'fail_reason_code' => 'draw_cancelled', + 'fail_reason_text' => 'draw_cancelled_refund', + 'risk_locked_amount' => 0, + ])->save(); + } + + $hasPostedDeduct = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $lockedOrder->order_no) + ->where('status', 'posted') + ->exists(); + + if ($hasPostedDeduct) { + $this->ticketWallet->reverseBetDeduct($lockedOrder); + } + + $lockedOrder->forceFill(['status' => 'refunded'])->save(); + } +} diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index eded5d3..9a4f40e 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -60,6 +60,14 @@ final class DrawHallSnapshotBuilder return DrawStatus::Closing->value; } + /** 与大厅 {@see effectiveHallDisplayStatus} 一致:是否仍接受 preview/place。 */ + public function isBettingOpen(Draw $draw, ?Carbon $nowUtc = null): bool + { + $nowUtc ??= now()->utc(); + + return $this->effectiveHallDisplayStatus($draw, $nowUtc) === DrawStatus::Open->value; + } + private function showsPublishedResults(string $drawStatus): bool { return in_array($drawStatus, [ diff --git a/app/Services/Draw/DrawManualResultService.php b/app/Services/Draw/DrawManualResultService.php index d6ab8dc..6e68551 100644 --- a/app/Services/Draw/DrawManualResultService.php +++ b/app/Services/Draw/DrawManualResultService.php @@ -28,6 +28,13 @@ final class DrawManualResultService throw new \RuntimeException('draw_already_settled'); } + if (DrawResultBatch::query() + ->where('draw_id', $locked->id) + ->where('status', DrawResultBatchStatus::PendingReview->value) + ->exists()) { + throw new \RuntimeException('draw_pending_result_batch_exists'); + } + $nextVersion = max(1, (int) $locked->current_result_version + 1); $batch = DrawResultBatch::query()->create([ 'draw_id' => $locked->id, diff --git a/app/Services/Draw/DrawPublishService.php b/app/Services/Draw/DrawPublishService.php index 5e56f10..2d86508 100644 --- a/app/Services/Draw/DrawPublishService.php +++ b/app/Services/Draw/DrawPublishService.php @@ -6,9 +6,11 @@ use App\Models\Draw; use App\Models\AdminUser; use App\Lottery\DrawStatus; use App\Models\DrawResultBatch; +use App\Models\SettlementBatch; use Illuminate\Support\Facades\DB; use App\Services\LotterySettings; use App\Lottery\DrawResultBatchStatus; +use App\Lottery\SettlementBatchStatus; /** * 人工审核通过后发布结果;或 RNG 自动生成路径内联调用同一事务字段更新。 @@ -114,10 +116,28 @@ final class DrawPublishService DrawStatus::Closed->value, DrawStatus::Review->value, DrawStatus::Cooldown->value, - DrawStatus::Settling->value, ]; if (! in_array($draw->status, $allowed, true)) { throw new \RuntimeException('draw_not_ready_to_publish'); } + + $this->assertNoActiveSettlementWorkflow($draw); + } + + /** 存在未完结结算批次时不允许改发布结果,避免派彩与大厅展示号码不一致。 */ + private function assertNoActiveSettlementWorkflow(Draw $draw): void + { + $active = SettlementBatch::query() + ->where('draw_id', $draw->id) + ->whereIn('status', [ + SettlementBatchStatus::Running->value, + SettlementBatchStatus::PendingReview->value, + SettlementBatchStatus::Approved->value, + ]) + ->exists(); + + if ($active) { + throw new \RuntimeException('draw_settlement_in_progress'); + } } } diff --git a/app/Services/Jackpot/JackpotContributionService.php b/app/Services/Jackpot/JackpotContributionService.php index d42efae..799aec8 100644 --- a/app/Services/Jackpot/JackpotContributionService.php +++ b/app/Services/Jackpot/JackpotContributionService.php @@ -35,6 +35,14 @@ final class JackpotContributionService return; } + $existing = JackpotContribution::query() + ->where('ticket_item_id', $item->id) + ->first(); + + if ($existing !== null) { + return; + } + JackpotContribution::query()->create([ 'jackpot_pool_id' => $pool->id, 'draw_id' => $draw->id, diff --git a/app/Services/Jackpot/JackpotPoolAdjustmentService.php b/app/Services/Jackpot/JackpotPoolAdjustmentService.php new file mode 100644 index 0000000..25a119c --- /dev/null +++ b/app/Services/Jackpot/JackpotPoolAdjustmentService.php @@ -0,0 +1,95 @@ +whereKey($pool->id)->lockForUpdate()->firstOrFail(); + + $before = (int) $locked->current_amount; + $after = $before + $amountDelta; + if ($after < 0) { + throw new \RuntimeException('adjustment_would_make_balance_negative'); + } + + $adjustment = JackpotPoolAdjustment::query()->create([ + 'adjustment_no' => $this->newAdjustmentNo(), + 'jackpot_pool_id' => $locked->id, + 'admin_user_id' => $admin->id, + 'amount_delta' => $amountDelta, + 'balance_before' => $before, + 'balance_after' => $after, + 'reason' => $reason, + ]); + + $locked->forceFill(['current_amount' => $after])->save(); + + $snapshot = [ + 'currency_code' => $locked->currency_code, + 'amount_delta' => $amountDelta, + 'balance_before' => $before, + 'balance_after' => $after, + 'reason' => $reason, + 'adjustment_no' => $adjustment->adjustment_no, + ]; + + if ($request !== null) { + AuditLogger::recordFromRequest( + $request, + AuditLogger::OPERATOR_ADMIN, + $admin->id, + 'jackpot', + 'adjust_balance', + 'jackpot_pool', + (string) $locked->id, + ['current_amount' => $before], + $snapshot, + ); + } else { + AuditLogger::record( + AuditLogger::OPERATOR_ADMIN, + $admin->id, + 'jackpot', + 'adjust_balance', + 'jackpot_pool', + (string) $locked->id, + ['current_amount' => $before], + $snapshot, + ); + } + + return $adjustment->fresh(['adminUser']); + }); + } + + private function newAdjustmentNo(): string + { + return 'JA'.now()->format('YmdHis').Str::upper(Str::random(6)); + } +} diff --git a/app/Services/Settlement/SettlementBatchWorkflowService.php b/app/Services/Settlement/SettlementBatchWorkflowService.php index a448114..2ef11bf 100644 --- a/app/Services/Settlement/SettlementBatchWorkflowService.php +++ b/app/Services/Settlement/SettlementBatchWorkflowService.php @@ -97,11 +97,15 @@ final class SettlementBatchWorkflowService $batchItemIds = $locked->details()->pluck('ticket_item_id')->map(fn ($id) => (int) $id)->all(); - $hasPendingDraw = TicketItem::query() + $hasUnsettled = TicketItem::query() ->where('draw_id', $locked->draw_id) - ->where('status', 'pending_draw') + ->whereIn('status', [ + 'pending_confirm', + 'partial_pending_confirm', + 'pending_draw', + ]) ->exists(); - if ($hasPendingDraw) { + if ($hasUnsettled) { throw new \RuntimeException('draw_has_unsettled_tickets'); } diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index b88e0e1..3189f4d 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -66,6 +66,7 @@ final class SettlementOrchestrator ->where('draw_id', $locked->id) ->where('result_batch_id', $publishedBatch->id) ->whereIn('status', [ + SettlementBatchStatus::Running->value, SettlementBatchStatus::PendingReview->value, SettlementBatchStatus::Approved->value, SettlementBatchStatus::Paid->value, diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index 689b7b0..39a1936 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -172,6 +172,28 @@ final class RiskPoolService } } + /** 后台改池或释池后,将 Redis 风控快照与 DB 对齐。 */ + public function syncRedisStateFromPool(RiskPool $pool): void + { + if (! $this->shouldUseRedisAtomicLocks()) { + return; + } + + $total = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + $remaining = max(0, $total - $locked); + + Redis::eval( + $this->overwriteStateLua(), + 1, + $this->redisPoolKey((int) $pool->draw_id, (string) $pool->normalized_number), + $total, + $locked, + $remaining, + (int) $pool->version, + ); + } + private function shouldUseRedisAtomicLocks(): bool { if (App::environment('testing')) { @@ -196,6 +218,14 @@ return 1 LUA; } + private function overwriteStateLua(): string + { + return <<<'LUA' +redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[3], 'version', ARGV[4]) +return 1 +LUA; + } + private function acquireLua(): string { return <<<'LUA' diff --git a/app/Services/Ticket/TicketPendingConfirmReconcileService.php b/app/Services/Ticket/TicketPendingConfirmReconcileService.php index 85facd0..a33fd5a 100644 --- a/app/Services/Ticket/TicketPendingConfirmReconcileService.php +++ b/app/Services/Ticket/TicketPendingConfirmReconcileService.php @@ -2,15 +2,21 @@ namespace App\Services\Ticket; +use App\Models\Draw; use App\Models\WalletTxn; use App\Models\TicketItem; use App\Models\TicketOrder; +use App\Services\Draw\DrawHallSnapshotBuilder; +use App\Services\Jackpot\JackpotContributionService; use Illuminate\Support\Facades\DB; final class TicketPendingConfirmReconcileService { public function __construct( private readonly RiskPoolService $riskPool, + private readonly JackpotContributionService $jackpotContribution, + private readonly DrawHallSnapshotBuilder $drawHallSnapshot, + private readonly TicketWalletService $ticketWallet, ) {} /** @@ -20,7 +26,7 @@ final class TicketPendingConfirmReconcileService { $cutoff = now()->subMinutes($staleMinutes); $orders = TicketOrder::query() - ->where('status', 'pending_confirm') + ->whereIn('status', ['pending_confirm', 'partial_pending_confirm']) ->where('updated_at', '<=', $cutoff) ->orderBy('id') ->limit($limit) @@ -35,7 +41,7 @@ final class TicketPendingConfirmReconcileService ->lockForUpdate() ->first(); - if ($lockedOrder === null || $lockedOrder->status !== 'pending_confirm') { + if ($lockedOrder === null || ! in_array($lockedOrder->status, ['pending_confirm', 'partial_pending_confirm'], true)) { return 'skipped'; } @@ -46,52 +52,10 @@ final class TicketPendingConfirmReconcileService ->exists(); if ($hasPostedDeduct) { - TicketItem::query() - ->where('order_id', $lockedOrder->id) - ->where('status', 'pending_confirm') - ->update([ - 'status' => 'pending_draw', - 'fail_reason_code' => null, - 'fail_reason_text' => null, - 'updated_at' => now(), - ]); - - $lockedOrder->forceFill(['status' => 'placed'])->save(); - - return 'confirmed'; + return $this->confirmOrder($lockedOrder); } - $items = TicketItem::query() - ->where('order_id', $lockedOrder->id) - ->where('status', 'pending_confirm') - ->with('combinations') - ->lockForUpdate() - ->get(); - - foreach ($items as $item) { - $locks = []; - foreach ($item->combinations as $combo) { - $locks[] = [ - 'number_4d' => (string) $combo->number_4d, - 'amount' => (int) $combo->estimated_payout, - ]; - } - - if ($locks !== []) { - $this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks); - } - - $item->forceFill([ - 'status' => 'refunded', - 'fail_reason_code' => 'pending_confirm_timeout', - 'fail_reason_text' => 'pending_confirm_timeout_refund', - 'risk_locked_amount' => 0, - ])->save(); - } - - $lockedOrder->forceFill(['status' => 'refunded'])->save(); - - return 'refunded'; + return $this->refundOrderWithoutDeduct($lockedOrder); }); if ($result === 'skipped') { @@ -109,4 +73,104 @@ final class TicketPendingConfirmReconcileService return $summary; } + + private function confirmOrder(TicketOrder $lockedOrder): string + { + $draw = Draw::query()->whereKey($lockedOrder->draw_id)->first(); + if ($draw === null || ! $this->drawHallSnapshot->isBettingOpen($draw)) { + return $this->refundStalePendingOrder( + $lockedOrder, + $draw === null ? 'draw_missing' : 'draw_no_longer_open', + ); + } + + $items = TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->where('status', 'pending_confirm') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $item->forceFill([ + 'status' => 'pending_draw', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + ])->save(); + + $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, (string) $lockedOrder->currency_code); + } + + $hasFailures = TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->where('status', 'failed') + ->exists(); + + $lockedOrder->forceFill([ + 'status' => $hasFailures ? 'partial_failed' : 'placed', + ])->save(); + + return 'confirmed'; + } + + private function refundStalePendingOrder(TicketOrder $lockedOrder, string $reasonCode): string + { + $hasPostedDeduct = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $lockedOrder->order_no) + ->where('status', 'posted') + ->exists(); + + if ($hasPostedDeduct) { + $this->ticketWallet->reverseBetDeduct($lockedOrder); + } + + return $this->refundPendingConfirmItems($lockedOrder, $reasonCode); + } + + private function refundOrderWithoutDeduct(TicketOrder $lockedOrder): string + { + return $this->refundPendingConfirmItems($lockedOrder, 'pending_confirm_timeout'); + } + + private function refundPendingConfirmItems(TicketOrder $lockedOrder, string $reasonCode): string + { + $items = TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->where('status', 'pending_confirm') + ->with('combinations') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $locks = []; + foreach ($item->combinations as $combo) { + $locks[] = [ + 'number_4d' => (string) $combo->number_4d, + 'amount' => (int) $combo->estimated_payout, + ]; + } + + if ($locks !== []) { + $this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks); + } + + $item->forceFill([ + 'status' => 'refunded', + 'fail_reason_code' => $reasonCode, + 'fail_reason_text' => $reasonCode.'_refund', + 'risk_locked_amount' => 0, + ])->save(); + } + + $hasFailures = TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->where('status', 'failed') + ->exists(); + + $lockedOrder->forceFill([ + 'status' => $hasFailures ? 'partial_failed' : 'refunded', + ])->save(); + + return 'refunded'; + } } diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 573c947..fc23311 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -7,13 +7,13 @@ use App\Models\Player; use App\Lottery\ErrorCode; use App\Models\TicketItem; use App\Models\WalletTxn; -use App\Lottery\DrawStatus; use App\Models\TicketOrder; use App\Models\PlayerWallet; use App\Models\TicketCombination; use Illuminate\Support\Facades\DB; use App\Exceptions\TicketOperationException; use App\Services\Jackpot\JackpotContributionService; +use App\Services\Draw\DrawHallSnapshotBuilder; final class TicketPlacementService { @@ -23,6 +23,7 @@ final class TicketPlacementService private readonly RiskPoolService $riskPoolService, private readonly TicketWalletService $ticketWalletService, private readonly JackpotContributionService $jackpotContribution, + private readonly DrawHallSnapshotBuilder $drawHallSnapshot, ) {} /** @@ -36,11 +37,14 @@ final class TicketPlacementService ? (string) $payload['client_trace_id'] : null; - if ($clientTraceId !== null) { + $drawNo = (string) $payload['draw_id']; + $drawIdForIdempotency = Draw::query()->where('draw_no', $drawNo)->value('id'); + + if ($clientTraceId !== null && $drawIdForIdempotency !== null) { $existing = TicketOrder::query() ->where('player_id', $player->id) + ->where('draw_id', $drawIdForIdempotency) ->where('client_trace_id', $clientTraceId) - ->whereIn('status', ['placed', 'partial_failed']) ->first(); if ($existing !== null) { @@ -72,7 +76,7 @@ final class TicketPlacementService if ($draw === null) { throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value); } - if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) { + if (! $this->drawHallSnapshot->isBettingOpen($draw)) { throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value); } @@ -132,13 +136,15 @@ final class TicketPlacementService ); } - $walletBalance = (int) (PlayerWallet::query() + $wallet = PlayerWallet::query() ->where('player_id', $player->id) ->where('wallet_type', 'lottery') ->where('currency_code', $currencyCode) ->lockForUpdate() - ->value('balance') ?? 0); - if ($walletBalance < $totalActualDeduct) { + ->first(); + $walletBalance = $wallet !== null ? (int) $wallet->balance : 0; + $walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0); + if ($walletAvailable < $totalActualDeduct) { throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); } @@ -333,6 +339,10 @@ final class TicketPlacementService $order = TicketOrder::query()->whereKey($order->id)->firstOrFail(); $draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail(); $successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'pending_draw')->count(); + $pendingConfirmCount = TicketItem::query() + ->where('order_id', $order->id) + ->whereIn('status', ['pending_confirm', 'partial_pending_confirm']) + ->count(); $failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count(); if ($balanceAfter === null) { $walletTxn = WalletTxn::query() @@ -350,11 +360,13 @@ final class TicketPlacementService 'status' => $draw->status, ], 'summary' => [ + 'order_status' => $order->status, 'total_bet_amount' => (int) $order->total_bet_amount, 'total_rebate_amount' => (int) $order->total_rebate_amount, 'total_actual_deduct' => (int) $order->total_actual_deduct, 'total_estimated_payout' => (int) $order->total_estimated_payout, 'success_count' => $successCount, + 'pending_confirm_count' => $pendingConfirmCount, 'failure_count' => $failureCount, ], 'balance_after' => $balanceAfter, diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index b937a10..3a76304 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -4,8 +4,8 @@ namespace App\Services\Ticket; use App\Models\Draw; use App\Lottery\ErrorCode; -use App\Lottery\DrawStatus; use App\Exceptions\TicketOperationException; +use App\Services\Draw\DrawHallSnapshotBuilder; final class TicketPreviewService { @@ -13,6 +13,7 @@ final class TicketPreviewService private readonly PlayCatalogResolver $catalogResolver, private readonly PlayRuleEngine $ruleEngine, private readonly RiskPoolService $riskPoolService, + private readonly DrawHallSnapshotBuilder $drawHallSnapshot, ) {} /** @@ -25,7 +26,7 @@ final class TicketPreviewService if ($draw === null) { throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value); } - if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) { + if (! $this->drawHallSnapshot->isBettingOpen($draw)) { throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value); } @@ -108,10 +109,13 @@ final class TicketPreviewService ); } + $nowUtc = now()->utc(); + return [ 'draw' => [ 'draw_id' => $draw->draw_no, - 'status' => $draw->status, + 'status' => $this->drawHallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc), + 'db_status' => $draw->status, ], 'config_versions' => $this->catalogResolver->currentActiveVersionStamp(), 'summary' => [ diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index 16bf1cd..78c91ce 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -39,8 +39,13 @@ final class TicketWalletService $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); } + if ((int) $wallet->status !== 0) { + throw new TicketOperationException('wallet_frozen', ErrorCode::WalletExternalRejected->value); + } + $before = (int) $wallet->balance; - if ($before < $amountMinor) { + $available = $before - (int) $wallet->frozen_balance; + if ($available < $amountMinor) { throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); } @@ -62,7 +67,7 @@ final class TicketWalletService 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, - 'idempotent_key' => $order->client_trace_id, + 'idempotent_key' => 'bet_deduct:'.$order->order_no, 'remark' => null, ]); @@ -191,6 +196,10 @@ final class TicketWalletService } $currency = strtoupper($currencyCode); + $idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id; + if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->where('biz_type', 'settle_payout')->exists()) { + return; + } $wallet = PlayerWallet::query() ->where('player_id', $player->id) @@ -231,7 +240,7 @@ final class TicketWalletService 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, - 'idempotent_key' => 'settle-payout:'.$settlementBatchId.':'.$player->id, + 'idempotent_key' => $idempotentKey, 'remark' => null, ]); } diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index 3b18cad..962603a 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -135,28 +135,44 @@ final class LotteryTransferService ); } - DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void { - $wallet = $this->lockLotteryWallet($player, $currencyCode); - $this->postLotteryWalletMovement( - wallet: $wallet, - bizType: self::BIZ_TRANSFER_IN, - direction: self::TXN_DIR_IN, - amountMinor: $amountMinor, - bizNo: $transferNo, - externalRefNo: $main->externalRefNo, - idempotentKey: $idempotentKey, - remark: null, - deltaSign: 1, - ); + try { + DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void { + $wallet = $this->lockLotteryWallet($player, $currencyCode); + $this->postLotteryWalletMovement( + wallet: $wallet, + bizType: self::BIZ_TRANSFER_IN, + direction: self::TXN_DIR_IN, + amountMinor: $amountMinor, + bizNo: $transferNo, + externalRefNo: $main->externalRefNo, + idempotentKey: $idempotentKey, + remark: null, + deltaSign: 1, + ); - $order->forceFill([ - 'status' => self::ST_SUCCESS, - 'external_ref_no' => $main->externalRefNo, - 'external_request_payload' => $main->requestPayload, - 'external_response_payload' => $main->responsePayload, - 'finished_at' => now(), - ])->save(); - }); + $order->forceFill([ + 'status' => self::ST_SUCCESS, + 'external_ref_no' => $main->externalRefNo, + 'external_request_payload' => $main->requestPayload, + 'external_response_payload' => $main->responsePayload, + 'finished_at' => now(), + ])->save(); + }); + } catch (\Throwable $e) { + $order->refresh(); + if ($order->status === self::ST_PROCESSING) { + $order->forceFill([ + 'status' => self::ST_PENDING_RECONCILE, + 'external_ref_no' => $main->externalRefNo, + 'external_request_payload' => $main->requestPayload, + 'external_response_payload' => $main->responsePayload, + 'fail_reason' => 'lottery_credit_failed', + 'finished_at' => null, + ])->save(); + } + + throw $e; + } return $this->successPayload( TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(), @@ -328,6 +344,12 @@ final class LotteryTransferService return; } + if ($action === 'complete_credit') { + DB::transaction(fn (): mixed => $this->completeStuckTransferInCredit($order, $remark)); + + return; + } + if ($action === 'manually_process') { DB::transaction(fn (): mixed => $this->doManuallyProcess($order, $remark)); @@ -388,6 +410,79 @@ final class LotteryTransferService ])->save(); } + /** + * 主站已扣款但彩票侧入账失败时,人工/对账补完成转入。 + */ + private function completeStuckTransferInCredit(TransferOrder $order, string $remark): void + { + /** @var TransferOrder $locked */ + $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail(); + + if ($locked->direction !== self::DIR_IN) { + throw new WalletOperationException( + 'invalid_reconcile_action', + ErrorCode::WalletExternalRejected->value, + 422, + ); + } + + if ($locked->status === self::ST_SUCCESS) { + return; + } + + if ($locked->status !== self::ST_PENDING_RECONCILE) { + throw new WalletOperationException( + 'order_not_pending_reconcile', + ErrorCode::WalletExternalRejected->value, + 422, + ); + } + + if (! $this->isEligibleForCompleteCredit($locked)) { + throw new WalletOperationException( + 'complete_credit_not_eligible', + ErrorCode::WalletExternalRejected->value, + 422, + ); + } + + $idempotentKey = (string) $locked->idempotent_key; + if (WalletTxn::query() + ->where('idempotent_key', $idempotentKey) + ->where('biz_type', self::BIZ_TRANSFER_IN) + ->where('status', self::TXN_POSTED) + ->exists()) { + $locked->forceFill([ + 'status' => self::ST_SUCCESS, + 'finished_at' => now(), + ])->save(); + + return; + } + + $player = Player::query()->whereKey($locked->player_id)->firstOrFail(); + $currencyCode = (string) $locked->currency_code; + $wallet = $this->lockLotteryWallet($player, $currencyCode); + + $this->postLotteryWalletMovement( + wallet: $wallet, + bizType: self::BIZ_TRANSFER_IN, + direction: self::TXN_DIR_IN, + amountMinor: (int) $locked->amount, + bizNo: $locked->transfer_no, + externalRefNo: $locked->external_ref_no, + idempotentKey: $idempotentKey, + remark: $remark ?: 'complete_stuck_transfer_in', + deltaSign: 1, + ); + + $locked->forceFill([ + 'status' => self::ST_SUCCESS, + 'fail_reason' => null, + 'finished_at' => now(), + ])->save(); + } + private function doManuallyProcess(TransferOrder $order, string $remark): void { /** @var TransferOrder $locked */ @@ -406,6 +501,14 @@ final class LotteryTransferService return; } + if ($locked->direction === self::DIR_OUT && $locked->status === self::ST_PENDING_RECONCILE) { + throw new WalletOperationException( + 'manually_process_requires_reverse_for_transfer_out', + ErrorCode::WalletExternalRejected->value, + 422, + ); + } + $locked->forceFill([ 'status' => self::ST_MANUALLY_PROCESSED, 'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'), @@ -413,6 +516,13 @@ final class LotteryTransferService ])->save(); } + /** 仅主站已扣款(有 external_ref_no)且彩票入账失败时可补完成转入。 */ + private function isEligibleForCompleteCredit(TransferOrder $order): bool + { + return $order->fail_reason === 'lottery_credit_failed' + && trim((string) $order->external_ref_no) !== ''; + } + private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet { $wallet = PlayerWallet::query() @@ -699,7 +809,8 @@ final class LotteryTransferService bool $requireBalance = false, ): array { $before = (int) $wallet->balance; - if ($requireBalance && $deltaSign < 0 && $before < $amountMinor) { + $available = $before - (int) $wallet->frozen_balance; + if ($requireBalance && $deltaSign < 0 && $available < $amountMinor) { throw new WalletOperationException( 'insufficient_balance', ErrorCode::WalletInsufficientBalance->value, diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 99c8963..0b28fa9 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -421,6 +421,8 @@ final class AdminAuthorizationRegistry ['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['code' => 'admin.jackpot.contributions.index', 'module_code' => 'jackpot', 'name' => '奖池注入记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/contributions', 'route_name' => 'api.v1.admin.jackpot.contributions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['code' => 'admin.jackpot.pools.update', 'module_code' => 'jackpot', 'name' => '更新奖池', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}', 'route_name' => 'api.v1.admin.jackpot.pools.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']], + ['code' => 'admin.jackpot.pools.adjustments.index', 'module_code' => 'jackpot', 'name' => '奖池调整流水', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/adjustments', 'route_name' => 'api.v1.admin.jackpot.pools.adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], + ['code' => 'admin.jackpot.pools.adjustments.store', 'module_code' => 'jackpot', 'name' => '奖池余额调整', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/adjustments', 'route_name' => 'api.v1.admin.jackpot.pools.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']], ['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manual_burst']], ['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']], @@ -438,6 +440,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']], ['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']], + ['code' => 'admin.wallet.transfer-orders.complete-credit', 'module_code' => 'wallet', 'name' => '补完成转入入账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/complete-credit', 'route_name' => 'api.v1.admin.wallet.transfer-orders.complete-credit', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']], ['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], diff --git a/database/migrations/2026_05_29_100000_add_unique_ticket_item_id_to_jackpot_contributions.php b/database/migrations/2026_05_29_100000_add_unique_ticket_item_id_to_jackpot_contributions.php new file mode 100644 index 0000000..19dd2a6 --- /dev/null +++ b/database/migrations/2026_05_29_100000_add_unique_ticket_item_id_to_jackpot_contributions.php @@ -0,0 +1,41 @@ +select('ticket_item_id') + ->whereNotNull('ticket_item_id') + ->groupBy('ticket_item_id') + ->havingRaw('count(*) > 1') + ->pluck('ticket_item_id'); + + foreach ($duplicateIds as $ticketItemId) { + $rows = DB::table('jackpot_contributions') + ->where('ticket_item_id', $ticketItemId) + ->orderByDesc('id') + ->pluck('id'); + $keep = $rows->shift(); + if ($keep !== null && $rows->isNotEmpty()) { + DB::table('jackpot_contributions')->whereIn('id', $rows->all())->delete(); + } + } + + Schema::table('jackpot_contributions', function (Blueprint $table): void { + $table->unique('ticket_item_id', 'uk_jackpot_contributions_ticket_item'); + }); + } + + public function down(): void + { + Schema::table('jackpot_contributions', function (Blueprint $table): void { + $table->dropUnique('uk_jackpot_contributions_ticket_item'); + }); + } +}; diff --git a/database/migrations/2026_05_30_100000_create_jackpot_pool_adjustments_table.php b/database/migrations/2026_05_30_100000_create_jackpot_pool_adjustments_table.php new file mode 100644 index 0000000..bd98382 --- /dev/null +++ b/database/migrations/2026_05_30_100000_create_jackpot_pool_adjustments_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('adjustment_no', 32)->unique(); + $table->foreignId('jackpot_pool_id')->constrained('jackpot_pools')->cascadeOnDelete(); + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->bigInteger('amount_delta')->comment('signed minor units; + increase pool'); + $table->bigInteger('balance_before'); + $table->bigInteger('balance_after'); + $table->string('reason', 500); + $table->timestamps(); + + $table->index(['jackpot_pool_id', 'created_at'], 'idx_jackpot_pool_adjustments_pool_created'); + }); + } + + public function down(): void + { + Schema::dropIfExists('jackpot_pool_adjustments'); + } +}; diff --git a/database/migrations/2026_05_30_100001_add_jackpot_pool_adjustment_api_resources.php b/database/migrations/2026_05_30_100001_add_jackpot_pool_adjustment_api_resources.php new file mode 100644 index 0000000..2276798 --- /dev/null +++ b/database/migrations/2026_05_30_100001_add_jackpot_pool_adjustment_api_resources.php @@ -0,0 +1,105 @@ + */ + private const RESOURCE_CODES = [ + 'admin.jackpot.pools.adjustments.index', + 'admin.jackpot.pools.adjustments.store', + ]; + + public function up(): void + { + $now = Carbon::now(); + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + $resources = collect(AdminAuthorizationRegistry::resources()) + ->filter(fn (array $item): bool => in_array($item['code'], self::RESOURCE_CODES, true)) + ->values(); + + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $roleResourceRows = DB::table('admin_role_menu_actions as rma') + ->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id') + ->where('arb.api_resource_id', (int) $resourceId) + ->select('rma.role_id') + ->distinct() + ->get(); + + if (Schema::hasTable('admin_role_api_resources')) { + foreach ($roleResourceRows as $row) { + DB::table('admin_role_api_resources')->updateOrInsert([ + 'role_id' => (int) $row->role_id, + 'api_resource_id' => (int) $resourceId, + ], []); + } + } + } + } + + public function down(): void + { + foreach (self::RESOURCE_CODES as $code) { + $resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id'); + if ($resourceId === null) { + continue; + } + + if (Schema::hasTable('admin_role_api_resources')) { + DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete(); + } + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } + } +}; diff --git a/lang/en/jackpot.php b/lang/en/jackpot.php new file mode 100644 index 0000000..740bf04 --- /dev/null +++ b/lang/en/jackpot.php @@ -0,0 +1,7 @@ + 'Adjustment amount cannot be zero', + 'adjustment_reason_required' => 'Adjustment reason is required', + 'adjustment_negative_balance' => 'Pool balance cannot be negative after adjustment', +]; diff --git a/lang/ne/jackpot.php b/lang/ne/jackpot.php new file mode 100644 index 0000000..a0ef7c4 --- /dev/null +++ b/lang/ne/jackpot.php @@ -0,0 +1,7 @@ + 'समायोजन रकम शून्य हुन सक्दैन', + 'adjustment_reason_required' => 'समायोजन कारण अनिवार्य छ', + 'adjustment_negative_balance' => 'समायोजन पछि पूल ब्यालेन्स ऋणात्मक हुन सक्दैन', +]; diff --git a/lang/zh/jackpot.php b/lang/zh/jackpot.php new file mode 100644 index 0000000..d002b18 --- /dev/null +++ b/lang/zh/jackpot.php @@ -0,0 +1,7 @@ + '调整金额不能为 0', + 'adjustment_reason_required' => '请填写调整原因', + 'adjustment_negative_balance' => '调整后奖池余额不能为负数', +]; diff --git a/routes/api/v1/admin/jackpot.php b/routes/api/v1/admin/jackpot.php index 4f3a52d..9063e12 100644 --- a/routes/api/v1/admin/jackpot.php +++ b/routes/api/v1/admin/jackpot.php @@ -3,9 +3,11 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolIndexController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolUpdateController; +use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolAdjustController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolManualBurstController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPayoutLogIndexController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexController; +use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolAdjustmentIndexController; /** * 管理员奖池管理路由。 @@ -20,6 +22,8 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.jackpot.payout-logs.index'); Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class) ->name('api.v1.admin.jackpot.contributions.index'); + Route::get('jackpot/pools/{pool}/adjustments', AdminJackpotPoolAdjustmentIndexController::class) + ->name('api.v1.admin.jackpot.pools.adjustments.index'); }); // 奖池修改(仅管理权限) @@ -27,6 +31,8 @@ Route::middleware('admin.api-resource') ->group(function (): void { Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class) ->name('api.v1.admin.jackpot.pools.update'); + Route::post('jackpot/pools/{pool}/adjustments', AdminJackpotPoolAdjustController::class) + ->name('api.v1.admin.jackpot.pools.adjustments.store'); Route::post('jackpot/pools/{pool}/manual-burst', AdminJackpotPoolManualBurstController::class) ->name('api.v1.admin.jackpot.pools.manual-burst'); }); diff --git a/routes/api/v1/admin/wallet.php b/routes/api/v1/admin/wallet.php index c90f695..5feddc6 100644 --- a/routes/api/v1/admin/wallet.php +++ b/routes/api/v1/admin/wallet.php @@ -36,6 +36,8 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.wallet.transfer-orders.reverse'); Route::post('wallet/transfer-orders/{transfer_no}/manually-process', [TransferOrderReconcileController::class, 'manuallyProcess']) ->name('api.v1.admin.wallet.transfer-orders.manually-process'); + Route::post('wallet/transfer-orders/{transfer_no}/complete-credit', [TransferOrderReconcileController::class, 'completeCredit']) + ->name('api.v1.admin.wallet.transfer-orders.complete-credit'); }); // 对账任务创建(仅管理权限) diff --git a/tests/Feature/AdminBusinessLogicGuardsTest.php b/tests/Feature/AdminBusinessLogicGuardsTest.php new file mode 100644 index 0000000..2af38ca --- /dev/null +++ b/tests/Feature/AdminBusinessLogicGuardsTest.php @@ -0,0 +1,243 @@ +create([ + 'username' => 'guards_admin_'.uniqid(), + 'name' => 'Guards Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('cannot publish result batch while settlement batch is pending review', function (): void { + $token = guardsAdminToken(); + + $draw = Draw::query()->create([ + 'draw_no' => '20260526-guard-1', + 'business_date' => '2026-05-26', + 'sequence_no' => 1, + 'status' => DrawStatus::Settling->value, + 'start_time' => now()->subHour(), + 'close_time' => now()->subMinutes(30), + 'draw_time' => now()->subMinutes(20), + 'cooling_end_time' => null, + 'result_source' => 'manual', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $published = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'manual', + 'rng_seed_hash' => null, + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + $pending = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 2, + 'source_type' => 'manual', + 'rng_seed_hash' => null, + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::PendingReview->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => null, + ]); + + SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $published->id, + 'settle_version' => 1, + 'status' => SettlementBatchStatus::PendingReview->value, + 'review_status' => 'pending', + 'started_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$pending->id}/publish") + ->assertStatus(409); +}); + +test('cannot create second pending result batch for same draw', function (): void { + $token = guardsAdminToken(); + + $draw = Draw::query()->create([ + 'draw_no' => '20260526-guard-2', + 'business_date' => '2026-05-26', + 'sequence_no' => 2, + 'status' => DrawStatus::Review->value, + 'start_time' => now()->subHour(), + 'close_time' => now()->subMinutes(30), + 'draw_time' => now()->subMinutes(20), + 'cooling_end_time' => null, + 'result_source' => 'manual', + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $items = []; + foreach (DrawPrizeLayout::slots() as $i => $slot) { + $items[] = [ + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT), + ]; + } + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items]) + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items]) + ->assertStatus(409); +}); + +test('admin cannot manually process transfer out pending reconcile order', function (): void { + $token = guardsAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'out-manual-block', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_manual_block', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 300, + 'idempotent_key' => 'out-manual-block-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TO_manual_block/manually-process') + ->assertStatus(422); + + expect(TransferOrder::query()->where('transfer_no', 'TO_manual_block')->value('status')) + ->toBe('pending_reconcile'); +}); + +test('admin cannot complete credit for main site timeout transfer in order', function (): void { + $token = guardsAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'in-complete-block', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 100, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_complete_block', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 500, + 'idempotent_key' => 'in-complete-block-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_block/complete-credit') + ->assertStatus(422); + + expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(100); +}); + +test('transfer order list hides manual process for out pending reconcile', function (): void { + $token = guardsAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'list-flags', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_list_flags', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 100, + 'idempotent_key' => 'list-flags-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $items = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/wallet/transfer-orders?player_id='.$player->id) + ->assertOk() + ->json('data.items'); + + $item = collect($items)->firstWhere('transfer_no', 'TO_list_flags'); + expect($item)->not->toBeNull(); + + expect($item['can_reverse'])->toBeTrue() + ->and($item['can_manually_process'])->toBeFalse() + ->and($item['can_complete_credit'])->toBeFalse(); +}); diff --git a/tests/Feature/AdminJackpotPoolAdjustmentTest.php b/tests/Feature/AdminJackpotPoolAdjustmentTest.php new file mode 100644 index 0000000..550bc13 --- /dev/null +++ b/tests/Feature/AdminJackpotPoolAdjustmentTest.php @@ -0,0 +1,110 @@ +seed(CurrencySeeder::class); +}); + +function jackpotAdjustAdminToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'jackpot_adjust_admin', + 'name' => 'Jackpot Adjust', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin cannot set current_amount via pool update', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $token = jackpotAdjustAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [ + 'current_amount' => 9_999_999, + ]) + ->assertStatus(422); +}); + +test('admin can apply jackpot pool balance adjustment with ledger row', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $pool->forceFill(['current_amount' => 1_000])->save(); + $token = jackpotAdjustAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments', [ + 'amount_delta' => 500, + 'reason' => 'manual top-up after reconciliation', + ]) + ->assertOk() + ->assertJsonPath('data.pool.current_amount', 1_500) + ->assertJsonPath('data.adjustment.amount_delta', 500) + ->assertJsonPath('data.adjustment.balance_before', 1_000) + ->assertJsonPath('data.adjustment.balance_after', 1_500); + + expect(JackpotPoolAdjustment::query()->where('jackpot_pool_id', $pool->id)->count())->toBe(1); + + expect( + AuditLog::query() + ->where('module_code', 'jackpot') + ->where('action_code', 'adjust_balance') + ->exists(), + )->toBeTrue(); +}); + +test('admin jackpot adjustment rejects negative balance', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $pool->forceFill(['current_amount' => 100])->save(); + $token = jackpotAdjustAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments', [ + 'amount_delta' => -200, + 'reason' => 'correction', + ]) + ->assertStatus(422); + + expect((int) $pool->fresh()->current_amount)->toBe(100); +}); + +test('admin can list jackpot pool adjustments', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $admin = AdminUser::query()->create([ + 'username' => 'adj_list_admin', + 'name' => 'List', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + JackpotPoolAdjustment::query()->create([ + 'adjustment_no' => 'JA_TEST_1', + 'jackpot_pool_id' => $pool->id, + 'admin_user_id' => $admin->id, + 'amount_delta' => 50, + 'balance_before' => 0, + 'balance_after' => 50, + 'reason' => 'seed', + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.adjustment_no', 'JA_TEST_1'); +}); diff --git a/tests/Feature/AdminWalletApiTest.php b/tests/Feature/AdminWalletApiTest.php index beece5c..c417022 100644 --- a/tests/Feature/AdminWalletApiTest.php +++ b/tests/Feature/AdminWalletApiTest.php @@ -159,6 +159,7 @@ test('admin transfer order list exposes available reconcile actions by status', ->and($byNo['TI_failed']['can_manually_process'])->toBeTrue() ->and($byNo['TI_wait']['can_reverse'])->toBeTrue() ->and($byNo['TI_wait']['can_manually_process'])->toBeTrue() + ->and($byNo['TI_wait']['can_complete_credit'])->toBeFalse() ->and($byNo['TI_done']['can_reverse'])->toBeFalse() ->and($byNo['TI_done']['can_manually_process'])->toBeFalse(); }); @@ -326,6 +327,56 @@ test('admin transfer reverse is idempotent under concurrent reconcile', function ->and(WalletTxn::query()->where('biz_type', 'reversal')->count())->toBe(1); }); +test('admin can complete stuck transfer in credit for pending reconcile order', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'complete-credit-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 500, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_complete_credit', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 2_000, + 'idempotent_key' => 'complete-credit-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => ['ok' => true], + 'external_response_payload' => ['ok' => true], + 'external_ref_no' => 'main-ref-1', + 'fail_reason' => 'lottery_credit_failed', + 'finished_at' => null, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_credit/complete-credit', [ + 'remark' => 'manual complete', + ]) + ->assertOk() + ->assertJsonPath('data.status', 'success'); + + $wallet->refresh(); + expect((int) $wallet->balance)->toBe(2_500) + ->and(TransferOrder::query()->where('transfer_no', 'TI_complete_credit')->value('status'))->toBe('success') + ->and(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(1); +}); + test('admin shows player wallets', function (): void { $token = makeAdminToken(); diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index f6e0faa..51f7a73 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -2,8 +2,14 @@ use Carbon\Carbon; use App\Models\Draw; +use App\Models\Player; use App\Models\AdminRole; use App\Models\AdminUser; +use App\Models\TicketItem; +use App\Models\TicketOrder; +use App\Models\PlayerWallet; +use App\Models\WalletTxn; +use App\Models\TicketCombination; use App\Lottery\DrawStatus; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; @@ -286,6 +292,132 @@ test('admin can cancel draw before results exist', function (): void { Carbon::setTestNow(); }); +test('admin cancel draw refunds open bets and releases risk', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 12:16:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-121b', + 'business_date' => '2026-05-09', + 'sequence_no' => 121, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->copy()->subMinute(), + 'close_time' => now()->copy()->addMinutes(10), + 'draw_time' => now()->copy()->addMinutes(15), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'cancel-refund-player', + 'username' => 'cancel_refund', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 49_900, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-CANCEL-REFUND', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 3000, + 'status' => 'placed', + 'submit_source' => 'h5', + 'client_trace_id' => 'cancel-refund-trace', + ]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'TK-CANCEL-REFUND', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'straight', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 3000, + 'risk_locked_amount' => 3000, + 'status' => 'pending_draw', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + ]); + + TicketCombination::query()->create([ + 'ticket_item_id' => $item->id, + 'combination_no' => 1, + 'number_4d' => '1234', + 'bet_amount' => 100, + 'estimated_payout' => 3000, + ]); + + WalletTxn::query()->create([ + 'txn_no' => 'WT-CANCEL-REFUND', + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'bet_deduct', + 'biz_no' => $order->order_no, + 'direction' => 2, + 'amount' => 100, + 'balance_before' => 50_000, + 'balance_after' => 49_900, + 'status' => 'posted', + 'external_ref_no' => null, + 'idempotent_key' => 'bet:'.$order->order_no, + 'remark' => null, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'draw_cancel_refund_admin', + 'name' => 'Draw Cancel Refund Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', DrawStatus::Cancelled->value); + + expect($order->fresh()->status)->toBe('refunded') + ->and($item->fresh()->status)->toBe('refunded') + ->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(50_000); + + Carbon::setTestNow(); +}); + test('admin can manually trigger rng for closed draw', function (): void { config(['lottery.draw.require_manual_review' => true]); Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC')); diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 0dbfc9c..742b055 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -333,6 +333,93 @@ test('ticket place is idempotent by player draw and client trace id', function ( expect((int) $wallet->balance)->toBe(200_000 - 12_400); }); +test('ticket place idempotency is scoped per draw not global trace', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw('20260511-001'); + + Draw::query()->create([ + 'draw_no' => '20260511-002', + 'business_date' => '2026-05-11', + 'sequence_no' => 2, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(2), + 'close_time' => now()->addMinutes(5), + 'draw_time' => now()->addMinutes(6), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $trace = 'shared-trace-two-draws'; + $payloadA = array_merge(ticketPreviewPayload('20260511-001'), ['client_trace_id' => $trace]); + $payloadB = array_merge(ticketPreviewPayload('20260511-002'), ['client_trace_id' => $trace]); + + $first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payloadA) + ->assertOk() + ->json('data.order_no'); + + $second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payloadB) + ->assertOk() + ->json('data.order_no'); + + expect($second)->not->toBe($first) + ->and(TicketOrder::query()->count())->toBe(2); +}); + +test('ticket place accepts draw pending in db when hall rules show open', function (): void { + $player = ticketPlayerWithWallet(); + Draw::query()->create([ + 'draw_no' => '20260511-pending-hall', + 'business_date' => '2026-05-11', + 'sequence_no' => 99, + 'status' => DrawStatus::Pending->value, + 'start_time' => now()->subMinute(), + 'close_time' => now()->addMinutes(5), + 'draw_time' => now()->addMinutes(6), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-pending-hall', + 'currency_code' => 'NPR', + 'client_trace_id' => 'pending-hall-open-place', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value); + + expect(TicketOrder::query()->where('client_trace_id', 'pending-hall-open-place')->exists())->toBeTrue(); +}); + +test('ticket place rejects bet when only frozen balance would cover stake', function (): void { + $player = ticketPlayerWithWallet(20_000); + ticketOpenDraw(); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + $wallet->forceFill(['balance' => 20_000, 'frozen_balance' => 19_000])->save(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', array_merge(ticketPreviewPayload(), [ + 'client_trace_id' => 'frozen-balance-guard', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ])) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value); +}); + test('box family estimated max payout is the sum of every expanded combination payout', function (): void { $player = ticketPlayerWithWallet(500_000); ticketOpenDraw(); @@ -1043,3 +1130,143 @@ test('ticket place reverses wallet and releases risk when post deduction confirm ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1) ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); }); + +test('ticket place idempotency replays refunded order for same trace', function (): void { + $player = ticketPlayerWithWallet(); + $draw = ticketOpenDraw(); + $trace = 'trace-refunded-replay'; + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-REFUNDED-IDEM', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 3000, + 'status' => 'refunded', + 'submit_source' => 'h5', + 'client_trace_id' => $trace, + ]); + + $payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => $trace]); + + $replay = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payload) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->json('data'); + + expect($replay['order_no'])->toBe($order->order_no) + ->and(TicketOrder::query()->count())->toBe(1) + ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); +}); + +test('ticket pending confirmation reconcile refunds when draw no longer accepts bets', function (): void { + $draw = ticketOpenDraw(); + $draw->forceFill([ + 'status' => DrawStatus::Closed->value, + 'close_time' => now()->subMinute(), + 'draw_time' => now()->subMinute(), + ])->save(); + + $player = ticketPlayerWithWallet(10_000); + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-PENDING-CLOSED', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 3000, + 'status' => 'pending_confirm', + 'submit_source' => 'h5', + 'client_trace_id' => 'pending-on-closed-draw', + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'TK-PENDING-CLOSED', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'straight', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 3000, + 'risk_locked_amount' => 3000, + 'status' => 'pending_confirm', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + + TicketCombination::query()->create([ + 'ticket_item_id' => $item->id, + 'combination_no' => 1, + 'number_4d' => '1234', + 'bet_amount' => 100, + 'estimated_payout' => 3000, + 'created_at' => now()->subMinutes(20), + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 5000, + 'locked_amount' => 3000, + 'remaining_amount' => 2000, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $wallet->forceFill(['balance' => 9_900])->save(); + + WalletTxn::query()->create([ + 'txn_no' => 'WL-PENDING-CLOSED', + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'bet_deduct', + 'biz_no' => 'TO-PENDING-CLOSED', + 'direction' => 2, + 'amount' => 100, + 'balance_before' => 10_000, + 'balance_after' => 9_900, + 'status' => 'posted', + 'external_ref_no' => null, + 'idempotent_key' => 'bet_deduct:TO-PENDING-CLOSED', + 'remark' => null, + ]); + + $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') + ->expectsOutputToContain('refunded: 1') + ->assertExitCode(0); + + expect($order->fresh()->status)->toBe('refunded') + ->and($item->fresh()->status)->toBe('refunded') + ->and($item->fresh()->fail_reason_code)->toBe('draw_no_longer_open') + ->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(10_000) + ->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1) + ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); +}); diff --git a/tests/Feature/WalletTransferTest.php b/tests/Feature/WalletTransferTest.php index 6db68b8..fed4156 100644 --- a/tests/Feature/WalletTransferTest.php +++ b/tests/Feature/WalletTransferTest.php @@ -122,6 +122,48 @@ test('transfer out debits lottery and matches stub credit', function () { expect((int) PlayerWallet::query()->where('player_id', $player->id)->first()?->balance)->toBe(600); }); +test('transfer out respects frozen balance when checking available funds', function () { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'u-frozen', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 10_000, + 'frozen_balance' => 9_500, + 'status' => 0, + 'version' => 0, + ]); + + $code = ErrorCode::WalletInsufficientBalance->value; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-out', [ + 'amount' => 1_000, + 'idempotent_key' => 'idem-out-frozen-too-much', + ]) + ->assertStatus(400) + ->assertJsonPath('code', $code); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-out', [ + 'amount' => 400, + 'idempotent_key' => 'idem-out-frozen-ok', + ]) + ->assertOk() + ->assertJsonPath('data.lottery_balance_after', 9_600); + + expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(9_600) + ->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('frozen_balance'))->toBe(9_500); +}); + test('transfer out insufficient balance fails with 1001', function () { $player = Player::query()->create([ 'site_code' => 'main',