diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php new file mode 100644 index 0000000..d854407 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php @@ -0,0 +1,46 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + $ran = $this->orchestrator->trySettleDraw($draw); + + $draw->refresh(); + + return ApiResponse::success([ + 'ran' => $ran, + 'draw_no' => $draw->draw_no, + 'status' => $draw->status, + 'settle_version' => (int) $draw->settle_version, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php new file mode 100644 index 0000000..c04e208 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php @@ -0,0 +1,54 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + $drawNo = trim((string) $request->query('draw_no', '')); + + $q = JackpotContribution::query() + ->with(['draw:id,draw_no', 'pool:id,currency_code', 'player:id,username,site_player_id', 'ticketItem:id,ticket_no']) + ->orderByDesc('id'); + + if ($drawNo !== '') { + $q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%')); + } + + $paginator = $q->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (JackpotContribution $r) => [ + 'id' => (int) $r->id, + 'draw_id' => (int) $r->draw_id, + 'draw_no' => $r->draw?->draw_no, + 'jackpot_pool_id' => (int) $r->jackpot_pool_id, + 'currency_code' => $r->pool?->currency_code, + 'player_id' => (int) $r->player_id, + 'player_username' => $r->player?->username, + 'ticket_item_id' => $r->ticket_item_id !== null ? (int) $r->ticket_item_id : null, + 'ticket_no' => $r->ticketItem?->ticket_no, + 'contribution_amount' => (int) $r->contribution_amount, + 'created_at' => $r->created_at?->toIso8601String(), + ])->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPayoutLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPayoutLogIndexController.php new file mode 100644 index 0000000..bc82f61 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPayoutLogIndexController.php @@ -0,0 +1,53 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + $drawNo = trim((string) $request->query('draw_no', '')); + + $q = JackpotPayoutLog::query() + ->with(['draw:id,draw_no', 'pool:id,currency_code']) + ->orderByDesc('id'); + + if ($drawNo !== '') { + $q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%')); + } + + $paginator = $q->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (JackpotPayoutLog $r) => [ + 'id' => (int) $r->id, + 'draw_id' => (int) $r->draw_id, + 'draw_no' => $r->draw?->draw_no, + 'jackpot_pool_id' => (int) $r->jackpot_pool_id, + 'currency_code' => $r->pool?->currency_code, + 'trigger_type' => $r->trigger_type, + 'total_payout_amount' => (int) $r->total_payout_amount, + 'winner_count' => (int) $r->winner_count, + 'trigger_snapshot_json' => $r->trigger_snapshot_json, + 'created_at' => $r->created_at?->toIso8601String(), + ])->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php new file mode 100644 index 0000000..4d904ef --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php @@ -0,0 +1,41 @@ +orderBy('currency_code')->get(); + + return ApiResponse::success([ + 'items' => $rows->map(fn (JackpotPool $p) => $this->row($p))->values()->all(), + ]); + } + + /** @return array */ + private function row(JackpotPool $p): array + { + return [ + 'id' => (int) $p->id, + 'currency_code' => $p->currency_code, + 'current_amount' => (int) $p->current_amount, + 'contribution_rate' => (string) $p->contribution_rate, + 'trigger_threshold' => (int) $p->trigger_threshold, + 'payout_rate' => (string) $p->payout_rate, + 'force_trigger_draw_gap' => (int) $p->force_trigger_draw_gap, + 'min_bet_amount' => (int) $p->min_bet_amount, + 'status' => (int) $p->status, + 'last_trigger_draw_id' => $p->last_trigger_draw_id !== null ? (int) $p->last_trigger_draw_id : null, + 'updated_at' => $p->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php new file mode 100644 index 0000000..02ff976 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php @@ -0,0 +1,45 @@ +validate([ + 'current_amount' => 'sometimes|integer|min:0', + 'contribution_rate' => 'sometimes|numeric|min:0|max:1', + 'trigger_threshold' => 'sometimes|integer|min:0', + 'payout_rate' => 'sometimes|numeric|min:0|max:1', + 'force_trigger_draw_gap' => 'sometimes|integer|min:0', + 'min_bet_amount' => 'sometimes|integer|min:0', + 'status' => 'sometimes|integer|in:0,1', + ]); + + $pool->fill($data); + $pool->save(); + + return ApiResponse::success([ + 'id' => (int) $pool->id, + 'currency_code' => $pool->currency_code, + 'current_amount' => (int) $pool->current_amount, + 'contribution_rate' => (string) $pool->contribution_rate, + 'trigger_threshold' => (int) $pool->trigger_threshold, + 'payout_rate' => (string) $pool->payout_rate, + 'force_trigger_draw_gap' => (int) $pool->force_trigger_draw_gap, + 'min_bet_amount' => (int) $pool->min_bet_amount, + 'status' => (int) $pool->status, + 'last_trigger_draw_id' => $pool->last_trigger_draw_id !== null ? (int) $pool->last_trigger_draw_id : null, + 'updated_at' => $pool->updated_at?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php new file mode 100644 index 0000000..06dc2d3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php @@ -0,0 +1,61 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + + $paginator = TicketSettlementDetail::query() + ->where('settlement_batch_id', $batch->id) + ->with([ + 'ticketItem:id,ticket_no,play_code,player_id', + 'ticketItem.player:id,username,site_player_id', + ]) + ->orderBy('id') + ->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'batch_id' => (int) $batch->id, + 'items' => collect($paginator->items())->map(function ($row) { + /** @var TicketSettlementDetail $row */ + $item = $row->ticketItem; + $player = $item?->player; + + return [ + 'id' => (int) $row->id, + 'ticket_item_id' => (int) $row->ticket_item_id, + 'ticket_no' => $item?->ticket_no, + 'play_code' => $item?->play_code, + 'player_id' => $item?->player_id, + 'player_username' => $player?->username, + 'site_player_id' => $player?->site_player_id, + 'matched_prize_tier' => $row->matched_prize_tier, + 'win_amount' => (int) $row->win_amount, + 'jackpot_allocation_amount' => (int) $row->jackpot_allocation_amount, + 'match_detail_json' => $row->match_detail_json, + 'created_at' => $row->created_at?->toIso8601String(), + ]; + })->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php new file mode 100644 index 0000000..eb806b2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php @@ -0,0 +1,67 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + $drawNo = trim((string) $request->query('draw_no', '')); + $status = trim((string) $request->query('status', '')); + + $q = SettlementBatch::query() + ->with(['draw:id,draw_no']) + ->orderByDesc('id'); + + if ($drawNo !== '') { + $q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%')); + } + + if ($status !== '') { + $q->where('status', $status); + } + + $paginator = $q->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (SettlementBatch $b) => $this->row($b))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(SettlementBatch $b): array + { + return [ + 'id' => (int) $b->id, + 'draw_id' => (int) $b->draw_id, + 'draw_no' => $b->draw?->draw_no, + 'result_batch_id' => (int) $b->result_batch_id, + 'settle_version' => (int) $b->settle_version, + 'status' => $b->status, + 'total_ticket_count' => (int) $b->total_ticket_count, + 'total_win_count' => (int) $b->total_win_count, + 'total_payout_amount' => (int) $b->total_payout_amount, + 'total_jackpot_payout_amount' => (int) $b->total_jackpot_payout_amount, + 'started_at' => $b->started_at?->toIso8601String(), + 'finished_at' => $b->finished_at?->toIso8601String(), + 'created_at' => $b->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php new file mode 100644 index 0000000..8112b23 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php @@ -0,0 +1,38 @@ +load(['draw:id,draw_no,business_date,status', 'resultBatch:id,result_version,status']); + + return ApiResponse::success([ + 'id' => (int) $batch->id, + 'draw_id' => (int) $batch->draw_id, + 'draw_no' => $batch->draw?->draw_no, + 'draw_status' => $batch->draw?->status, + 'result_batch_id' => (int) $batch->result_batch_id, + 'result_batch_version' => $batch->resultBatch?->result_version, + 'result_batch_status' => $batch->resultBatch?->status, + 'settle_version' => (int) $batch->settle_version, + 'status' => $batch->status, + 'total_ticket_count' => (int) $batch->total_ticket_count, + 'total_win_count' => (int) $batch->total_win_count, + 'total_payout_amount' => (int) $batch->total_payout_amount, + 'total_jackpot_payout_amount' => (int) $batch->total_jackpot_payout_amount, + 'started_at' => $batch->started_at?->toIso8601String(), + 'finished_at' => $batch->finished_at?->toIso8601String(), + 'created_at' => $batch->created_at?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php b/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php new file mode 100644 index 0000000..c995642 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php @@ -0,0 +1,34 @@ +query('currency_code', 'NPR'))); + if (strlen($currency) > 16) { + $currency = 'NPR'; + } + + $pool = JackpotPool::query() + ->where('currency_code', $currency) + ->where('status', 1) + ->first(); + + return ApiResponse::success([ + 'currency_code' => $currency, + 'enabled' => $pool !== null, + 'current_amount_minor' => $pool !== null ? (int) $pool->current_amount : 0, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php b/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php new file mode 100644 index 0000000..305f6e3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Ticket/TicketDrawMyMatchController.php @@ -0,0 +1,101 @@ +attributes->get('lottery_player'); + $draw_no = trim($draw_no); + + $draw = Draw::query()->where('draw_no', $draw_no)->first(); + if ($draw === null || ! in_array($draw->status, DrawResultViewService::publishedDrawStatuses(), true)) { + return ApiResponse::success([ + 'draw_no' => $draw_no, + 'hit_numbers_4d' => [], + 'total_win_minor' => 0, + 'total_jackpot_win_minor' => 0, + 'has_bets' => false, + ]); + } + + $payload = $this->drawResultView->summarizeDraw($draw); + if ($payload === null) { + return ApiResponse::success([ + 'draw_no' => $draw_no, + 'hit_numbers_4d' => [], + 'total_win_minor' => 0, + 'total_jackpot_win_minor' => 0, + 'has_bets' => false, + ]); + } + + $board = collect($payload['result_items'] ?? []) + ->pluck('number_4d') + ->filter() + ->map(fn ($n) => self::norm4d((string) $n)) + ->unique() + ->flip(); + + $itemIds = TicketItem::query() + ->where('draw_id', $draw->id) + ->where('player_id', $player->id) + ->whereIn('status', ['success', 'settled_win', 'settled_lose']) + ->pluck('id'); + + $hasBets = $itemIds->isNotEmpty(); + + $hits = []; + if ($hasBets) { + $hits = TicketCombination::query() + ->whereIn('ticket_item_id', $itemIds) + ->pluck('number_4d') + ->map(fn ($n) => self::norm4d((string) $n)) + ->filter(fn (string $n) => isset($board[$n])) + ->unique() + ->values() + ->all(); + } + + $sums = TicketItem::query() + ->where('draw_id', $draw->id) + ->where('player_id', $player->id) + ->whereIn('status', ['settled_win', 'settled_lose']) + ->selectRaw('coalesce(sum(win_amount),0) as sum_win, coalesce(sum(jackpot_win_amount),0) as sum_jackpot') + ->first(); + + return ApiResponse::success([ + 'draw_no' => $draw_no, + 'hit_numbers_4d' => $hits, + 'total_win_minor' => (int) ($sums->sum_win ?? 0), + 'total_jackpot_win_minor' => (int) ($sums->sum_jackpot ?? 0), + 'has_bets' => $hasBets, + ]); + } + + private static function norm4d(string $n): string + { + $n = preg_replace('/\D/', '', $n) ?? ''; + + return str_pad(substr($n, -4), 4, '0', STR_PAD_LEFT); + } +} diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php new file mode 100644 index 0000000..20c6c18 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php @@ -0,0 +1,89 @@ +attributes->get('lottery_player'); + $ticket_no = trim($ticket_no); + + $item = TicketItem::query() + ->where('ticket_no', $ticket_no) + ->where('player_id', $player->id) + ->with([ + 'combinations', + 'draw', + 'order', + 'latestSettlementDetail', + ]) + ->first(); + + if ($item === null) { + return ApiResponse::error( + trans('api.not_found', [], $request->lotteryLocale()), + ErrorCode::NotFound->value, + null, + 404, + ); + } + + $draw = $item->draw; + $published = $draw !== null && in_array($draw->status, DrawResultViewService::publishedDrawStatuses(), true); + $drawPayload = $published && $draw !== null ? $this->drawResultView->summarizeDraw($draw) : null; + + $detail = $item->latestSettlementDetail; + + return ApiResponse::success([ + 'ticket_no' => $item->ticket_no, + 'order_no' => $item->order?->order_no, + 'draw_no' => $draw?->draw_no, + 'currency_code' => $item->order?->currency_code, + 'play_code' => $item->play_code, + 'dimension' => $item->dimension, + 'digit_slot' => $item->digit_slot, + 'original_number' => $item->original_number, + 'normalized_number' => $item->normalized_number, + 'unit_bet_amount' => (int) $item->unit_bet_amount, + 'total_bet_amount' => (int) $item->total_bet_amount, + 'rebate_rate_snapshot' => (string) $item->rebate_rate_snapshot, + 'actual_deduct_amount' => (int) $item->actual_deduct_amount, + 'status' => $item->status, + 'win_amount' => (int) $item->win_amount, + 'jackpot_win_amount' => (int) $item->jackpot_win_amount, + 'settled_at' => $item->settled_at?->toIso8601String(), + 'placed_at' => $item->order?->created_at?->toIso8601String(), + 'odds_snapshot_json' => $item->odds_snapshot_json, + 'combinations' => $item->combinations->map(fn ($c) => [ + 'combination_no' => (int) $c->combination_no, + 'number_4d' => (string) $c->number_4d, + 'bet_amount' => (int) $c->bet_amount, + 'estimated_payout' => (int) $c->estimated_payout, + ])->values()->all(), + 'settlement' => $detail === null ? null : [ + 'matched_prize_tier' => $detail->matched_prize_tier, + 'win_amount_minor' => (int) $detail->win_amount, + 'jackpot_allocation_minor' => (int) $detail->jackpot_allocation_amount, + ], + 'published_draw_results' => $drawPayload, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php new file mode 100644 index 0000000..7a3bb91 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -0,0 +1,67 @@ +attributes->get('lottery_player'); + + $perPage = max(1, min(50, (int) $request->query('per_page', 20))); + $page = max(1, (int) $request->query('page', 1)); + $drawNo = $request->query('draw_no'); + + $query = TicketItem::query() + ->where('ticket_items.player_id', $player->id) + ->with([ + 'draw:id,draw_no,business_date', + 'order:id,order_no,currency_code,created_at', + ]) + ->orderByDesc('ticket_items.id'); + + if (is_string($drawNo) && $drawNo !== '') { + $drawNo = trim($drawNo); + $query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo)); + } + + $paginator = $query->paginate(perPage: $perPage, page: $page); + + $items = collect($paginator->items())->map(function (TicketItem $row): array { + return [ + 'ticket_no' => $row->ticket_no, + 'order_no' => $row->order?->order_no, + 'draw_no' => $row->draw?->draw_no, + 'currency_code' => $row->order?->currency_code, + 'play_code' => $row->play_code, + 'original_number' => $row->original_number, + 'total_bet_amount' => (int) $row->total_bet_amount, + 'actual_deduct_amount' => (int) $row->actual_deduct_amount, + 'status' => $row->status, + 'win_amount' => (int) $row->win_amount, + 'jackpot_win_amount' => (int) $row->jackpot_win_amount, + 'placed_at' => $row->order?->created_at?->toIso8601String(), + 'updated_at' => $row->updated_at?->toIso8601String(), + ]; + })->values()->all(); + + return ApiResponse::success([ + 'items' => $items, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'last_page' => $paginator->lastPage(), + ]); + } +} diff --git a/app/Lottery/SettlementBatchStatus.php b/app/Lottery/SettlementBatchStatus.php new file mode 100644 index 0000000..6f50620 --- /dev/null +++ b/app/Lottery/SettlementBatchStatus.php @@ -0,0 +1,13 @@ + 'integer', + 'draw_id' => 'integer', + 'player_id' => 'integer', + 'ticket_item_id' => 'integer', + 'contribution_amount' => 'integer', + ]; + } + + public function pool(): BelongsTo + { + return $this->belongsTo(JackpotPool::class, 'jackpot_pool_id'); + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function player(): BelongsTo + { + return $this->belongsTo(Player::class); + } + + public function ticketItem(): BelongsTo + { + return $this->belongsTo(TicketItem::class, 'ticket_item_id'); + } +} diff --git a/app/Models/JackpotPayoutLog.php b/app/Models/JackpotPayoutLog.php new file mode 100644 index 0000000..3c8f59c --- /dev/null +++ b/app/Models/JackpotPayoutLog.php @@ -0,0 +1,42 @@ + 'integer', + 'jackpot_pool_id' => 'integer', + 'total_payout_amount' => 'integer', + 'winner_count' => 'integer', + 'trigger_snapshot_json' => 'json', + ]; + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function pool(): BelongsTo + { + return $this->belongsTo(JackpotPool::class, 'jackpot_pool_id'); + } +} diff --git a/app/Models/JackpotPool.php b/app/Models/JackpotPool.php new file mode 100644 index 0000000..d7d804f --- /dev/null +++ b/app/Models/JackpotPool.php @@ -0,0 +1,47 @@ + 'integer', + 'contribution_rate' => 'decimal:4', + 'trigger_threshold' => 'integer', + 'payout_rate' => 'decimal:4', + 'force_trigger_draw_gap' => 'integer', + 'min_bet_amount' => 'integer', + 'status' => 'integer', + 'last_trigger_draw_id' => 'integer', + ]; + } + + public function lastTriggerDraw(): BelongsTo + { + return $this->belongsTo(Draw::class, 'last_trigger_draw_id'); + } + + public function contributions(): HasMany + { + return $this->hasMany(JackpotContribution::class, 'jackpot_pool_id'); + } +} diff --git a/app/Models/SettlementBatch.php b/app/Models/SettlementBatch.php new file mode 100644 index 0000000..0d4795c --- /dev/null +++ b/app/Models/SettlementBatch.php @@ -0,0 +1,60 @@ + 'integer', + 'result_batch_id' => 'integer', + 'settle_version' => 'integer', + 'total_ticket_count' => 'integer', + 'total_win_count' => 'integer', + 'total_payout_amount' => 'integer', + 'total_jackpot_payout_amount' => 'integer', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function resultBatch(): BelongsTo + { + return $this->belongsTo(DrawResultBatch::class, 'result_batch_id'); + } + + public function details(): HasMany + { + return $this->hasMany(TicketSettlementDetail::class, 'settlement_batch_id'); + } + + public function statusEnum(): ?SettlementBatchStatus + { + return SettlementBatchStatus::tryFrom((string) $this->status); + } +} diff --git a/app/Models/TicketItem.php b/app/Models/TicketItem.php index d7cd5c3..f145485 100644 --- a/app/Models/TicketItem.php +++ b/app/Models/TicketItem.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; /** 注项明细 {@see ticket_items} */ class TicketItem extends Model @@ -81,4 +82,9 @@ class TicketItem extends Model { return $this->hasMany(TicketCombination::class, 'ticket_item_id'); } + + public function latestSettlementDetail(): HasOne + { + return $this->hasOne(TicketSettlementDetail::class, 'ticket_item_id')->latestOfMany('id'); + } } diff --git a/app/Models/TicketSettlementDetail.php b/app/Models/TicketSettlementDetail.php new file mode 100644 index 0000000..5972064 --- /dev/null +++ b/app/Models/TicketSettlementDetail.php @@ -0,0 +1,40 @@ + 'integer', + 'ticket_item_id' => 'integer', + 'win_amount' => 'integer', + 'jackpot_allocation_amount' => 'integer', + 'match_detail_json' => 'json', + ]; + } + + public function batch(): BelongsTo + { + return $this->belongsTo(SettlementBatch::class, 'settlement_batch_id'); + } + + public function ticketItem(): BelongsTo + { + return $this->belongsTo(TicketItem::class, 'ticket_item_id'); + } +} diff --git a/app/Services/Draw/DrawTickService.php b/app/Services/Draw/DrawTickService.php index 48ac046..fe27726 100644 --- a/app/Services/Draw/DrawTickService.php +++ b/app/Services/Draw/DrawTickService.php @@ -4,6 +4,8 @@ namespace App\Services\Draw; use App\Lottery\DrawStatus; use App\Models\Draw; +use App\Services\LotterySettings; +use App\Services\Settlement\SettlementOrchestrator; use Carbon\Carbon; /** @@ -18,6 +20,7 @@ final class DrawTickService private readonly DrawRngRunner $rng, private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly LotteryHallRealtimeBroadcaster $hallRealtime, + private readonly SettlementOrchestrator $settlementOrchestrator, ) {} /** @@ -41,11 +44,14 @@ final class DrawTickService 'cooldown_to_settling' => $this->cooldownToSettling($nowUtc), ]; + $settlingSettled = $this->settleSettlingDraws(); + $rngOutcome = $this->rng->runDue($nowUtc); $planned = $this->planner->ensureBuffer($nowUtc); $report = [ 'status_updates' => $statusUpdates, + 'settling_settled' => $settlingSettled, 'rng_rung' => $rngOutcome['rung'], 'rng_errors' => $rngOutcome['errors'], 'planned' => $planned, @@ -131,4 +137,34 @@ final class DrawTickService ->where('cooling_end_time', '<=', $nowUtc) ->update(['status' => DrawStatus::Settling->value]); } + + /** + * 冷静期结束后已进入 `settling` 的期号:执行阶段 6 结算(可经 lottery_settings 关闭自动跑批)。 + * + * @return int 成功跑完结算的期号数量 + */ + private function settleSettlingDraws(): int + { + if (! (bool) LotterySettings::get('settlement.auto_run_on_tick', true)) { + return 0; + } + + $n = 0; + $ids = Draw::query()->where('status', DrawStatus::Settling->value)->pluck('id'); + foreach ($ids as $drawId) { + $draw = Draw::query()->find($drawId); + if ($draw === null) { + continue; + } + try { + if ($this->settlementOrchestrator->trySettleDraw($draw)) { + $n++; + } + } catch (\Throwable $e) { + report($e); + } + } + + return $n; + } } diff --git a/app/Services/Jackpot/JackpotBurstAllocator.php b/app/Services/Jackpot/JackpotBurstAllocator.php new file mode 100644 index 0000000..76db7c4 --- /dev/null +++ b/app/Services/Jackpot/JackpotBurstAllocator.php @@ -0,0 +1,107 @@ + $results + * @return array{allocations: array, pool_payout: int, trigger: ?string} + */ + public function allocate(Draw $draw, JackpotPool $pool, Collection $results): array + { + $winners = $results->filter( + fn (array $r) => ($r['matched_tier'] ?? null) === 'first' && (int) $r['gross_win'] > 0, + ); + + if ($winners->isEmpty()) { + return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; + } + + $thresholdOk = (int) $pool->current_amount >= (int) $pool->trigger_threshold; + $gapOk = $this->gapTriggerMet($pool); + if (! $thresholdOk && ! $gapOk) { + return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; + } + + $trigger = $thresholdOk ? 'threshold' : 'forced_gap'; + + $poolBefore = (int) $pool->current_amount; + $poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate); + if ($poolPayout <= 0) { + return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; + } + + $list = $winners->values()->all(); + $weightTotal = 0; + foreach ($list as $r) { + $weightTotal += (int) $r['item']->total_bet_amount; + } + if ($weightTotal <= 0) { + return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; + } + + $allocations = []; + $remaining = $poolPayout; + $n = count($list); + foreach ($list as $idx => $r) { + /** @var TicketItem $item */ + $item = $r['item']; + $w = (int) $item->total_bet_amount; + if ($idx === $n - 1) { + $share = max(0, $remaining); + } else { + $share = (int) floor($poolPayout * $w / $weightTotal); + $remaining -= $share; + } + $allocations[(int) $item->id] = $share; + } + + $pool->forceFill([ + 'current_amount' => max(0, $poolBefore - $poolPayout), + 'last_trigger_draw_id' => $draw->id, + ])->save(); + + JackpotPayoutLog::query()->create([ + 'draw_id' => $draw->id, + 'jackpot_pool_id' => $pool->id, + 'trigger_type' => $trigger, + 'total_payout_amount' => $poolPayout, + 'winner_count' => count($allocations), + 'trigger_snapshot_json' => [ + 'threshold_ok' => $thresholdOk, + 'gap_ok' => $gapOk, + 'pool_amount_before' => $poolBefore, + 'payout_rate' => (string) $pool->payout_rate, + ], + ]); + + return ['allocations' => $allocations, 'pool_payout' => $poolPayout, 'trigger' => $trigger]; + } + + private function gapTriggerMet(JackpotPool $pool): bool + { + $gap = (int) $pool->force_trigger_draw_gap; + if ($gap <= 0) { + return false; + } + + $lastId = (int) ($pool->last_trigger_draw_id ?? 0); + $count = Draw::query() + ->where('status', DrawStatus::Settled->value) + ->when($lastId > 0, fn ($q) => $q->where('id', '>', $lastId)) + ->count(); + + return $count >= $gap; + } +} diff --git a/app/Services/Jackpot/JackpotContributionService.php b/app/Services/Jackpot/JackpotContributionService.php new file mode 100644 index 0000000..780fac9 --- /dev/null +++ b/app/Services/Jackpot/JackpotContributionService.php @@ -0,0 +1,51 @@ +where('currency_code', $currency) + ->where('status', 1) + ->lockForUpdate() + ->first(); + + if ($pool === null) { + return; + } + + if ((int) $item->actual_deduct_amount < (int) $pool->min_bet_amount) { + return; + } + + $rate = (float) $pool->contribution_rate; + $contrib = (int) floor((int) $item->actual_deduct_amount * $rate); + if ($contrib <= 0) { + return; + } + + JackpotContribution::query()->create([ + 'jackpot_pool_id' => $pool->id, + 'draw_id' => $draw->id, + 'player_id' => $item->player_id, + 'ticket_item_id' => $item->id, + 'contribution_amount' => $contrib, + 'currency_code' => $currency, + ]); + + $pool->forceFill([ + 'current_amount' => (int) $pool->current_amount + $contrib, + ])->save(); + } +} diff --git a/app/Services/Settlement/Contracts/SettlementPlayMatcher.php b/app/Services/Settlement/Contracts/SettlementPlayMatcher.php new file mode 100644 index 0000000..facb1dd --- /dev/null +++ b/app/Services/Settlement/Contracts/SettlementPlayMatcher.php @@ -0,0 +1,17 @@ + $combinations + * @return array{win_amount: int, matched_prize_tier: ?string, match_detail: array} + */ + public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array; +} diff --git a/app/Services/Settlement/Matchers/BigSpreadSettlementMatcher.php b/app/Services/Settlement/Matchers/BigSpreadSettlementMatcher.php new file mode 100644 index 0000000..659f2af --- /dev/null +++ b/app/Services/Settlement/Matchers/BigSpreadSettlementMatcher.php @@ -0,0 +1,59 @@ +odds_snapshot_json) ? $item->odds_snapshot_json : null; + $lines = []; + $total = 0; + $bestTier = null; + $bestRank = 99; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $hit = $board->bestTierForNumber((string) $c->number_4d); + if ($hit === null) { + continue; + } + $tier = $hit['tier']; + $oddsVal = $this->odds->oddsValueForScope($snapshot, $tier); + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = [ + 'number_4d' => $c->number_4d, + 'matched_tier' => $tier, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $payout, + ]; + if ($hit['rank'] < $bestRank) { + $bestRank = $hit['rank']; + $bestTier = $tier; + } + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $bestTier, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/FirstPrizeComboSettlementMatcher.php b/app/Services/Settlement/Matchers/FirstPrizeComboSettlementMatcher.php new file mode 100644 index 0000000..dd30278 --- /dev/null +++ b/app/Services/Settlement/Matchers/FirstPrizeComboSettlementMatcher.php @@ -0,0 +1,50 @@ +firstPrizeNumber4d(); + if ($first === '') { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_first']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $oddsVal = $this->odds->oddsValueForScope($snapshot, 'first'); + $lines = []; + $total = 0; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + if ((string) $c->number_4d !== $first) { + continue; + } + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $c->number_4d, 'bet_amount' => $bet, 'payout' => $payout]; + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $total > 0 ? 'first' : null, + 'match_detail' => ['lines' => $lines, 'first_prize' => $first], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/NoopSettlementMatcher.php b/app/Services/Settlement/Matchers/NoopSettlementMatcher.php new file mode 100644 index 0000000..ea15d91 --- /dev/null +++ b/app/Services/Settlement/Matchers/NoopSettlementMatcher.php @@ -0,0 +1,23 @@ + 0, + 'matched_prize_tier' => null, + 'match_detail' => ['play_code' => $item->play_code, 'skipped' => true], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php new file mode 100644 index 0000000..c272a72 --- /dev/null +++ b/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php @@ -0,0 +1,83 @@ +suffix2ForTier($t, 0); + if ($s !== '') { + $suffixByTier[$t] = $s; + } + } + if ($suffixByTier === []) { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $lines = []; + $total = 0; + $bestTier = null; + $bestRank = 99; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $n = (string) $c->number_4d; + if (strlen($n) < 2) { + continue; + } + $suf = substr($n, -2); + $hitTier = null; + $rank = 99; + foreach ($suffixByTier as $t => $sx) { + if ($suf !== $sx) { + continue; + } + $r = match ($t) { + 'first' => 0, + 'second' => 1, + 'third' => 2, + default => 99, + }; + if ($r < $rank) { + $rank = $r; + $hitTier = $t; + } + } + if ($hitTier === null) { + continue; + } + $oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier); + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout]; + if ($rank < $bestRank) { + $bestRank = $rank; + $bestTier = $hitTier; + } + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $bestTier, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php new file mode 100644 index 0000000..0c16a38 --- /dev/null +++ b/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php @@ -0,0 +1,57 @@ + */ + private const PLAY_TO_TIER = [ + 'pos_2a' => 'first', + 'pos_2b' => 'second', + 'pos_2c' => 'third', + ]; + + public function __construct( + private readonly OddsSnapshotReader $odds, + ) {} + + public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array + { + $tier = self::PLAY_TO_TIER[$item->play_code] ?? 'first'; + $suffix = $board->suffix2ForTier($tier, 0); + if ($suffix === '') { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $oddsVal = $this->odds->oddsValueForScope($snapshot, $tier); + $lines = []; + $total = 0; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $n = (string) $c->number_4d; + if (strlen($n) < 2 || substr($n, -2) !== $suffix) { + continue; + } + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $n, 'suffix2' => $suffix, 'payout' => $payout]; + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $total > 0 ? $tier : null, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php new file mode 100644 index 0000000..69c5c7f --- /dev/null +++ b/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php @@ -0,0 +1,83 @@ +suffix3ForTier($t, 0); + if ($s !== '') { + $suffixByTier[$t] = $s; + } + } + if ($suffixByTier === []) { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $lines = []; + $total = 0; + $bestTier = null; + $bestRank = 99; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $n = (string) $c->number_4d; + if (strlen($n) < 3) { + continue; + } + $suf = substr($n, -3); + $hitTier = null; + $rank = 99; + foreach ($suffixByTier as $t => $sx) { + if ($suf !== $sx) { + continue; + } + $r = match ($t) { + 'first' => 0, + 'second' => 1, + 'third' => 2, + default => 99, + }; + if ($r < $rank) { + $rank = $r; + $hitTier = $t; + } + } + if ($hitTier === null) { + continue; + } + $oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier); + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout]; + if ($rank < $bestRank) { + $bestRank = $rank; + $bestTier = $hitTier; + } + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $bestTier, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php new file mode 100644 index 0000000..0cda6d8 --- /dev/null +++ b/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php @@ -0,0 +1,57 @@ + */ + private const PLAY_TO_TIER = [ + 'pos_3a' => 'first', + 'pos_3b' => 'second', + 'pos_3c' => 'third', + ]; + + public function __construct( + private readonly OddsSnapshotReader $odds, + ) {} + + public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array + { + $tier = self::PLAY_TO_TIER[$item->play_code] ?? 'first'; + $suffix = $board->suffix3ForTier($tier, 0); + if ($suffix === '') { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $oddsVal = $this->odds->oddsValueForScope($snapshot, $tier); + $lines = []; + $total = 0; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $n = (string) $c->number_4d; + if (strlen($n) < 3 || substr($n, -3) !== $suffix) { + continue; + } + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $n, 'suffix3' => $suffix, 'payout' => $payout]; + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $total > 0 ? $tier : null, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/Pos4ExactTierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos4ExactTierSettlementMatcher.php new file mode 100644 index 0000000..a18a750 --- /dev/null +++ b/app/Services/Settlement/Matchers/Pos4ExactTierSettlementMatcher.php @@ -0,0 +1,56 @@ + */ + private const PLAY_TO_TIER = [ + 'pos_4a' => 'first', + 'pos_4b' => 'second', + 'pos_4c' => 'third', + ]; + + public function __construct( + private readonly OddsSnapshotReader $odds, + ) {} + + public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array + { + $tier = self::PLAY_TO_TIER[$item->play_code] ?? 'first'; + $row = $board->row($tier, 0); + if ($row === null) { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_row']]; + } + $target = (string) $row->number_4d; + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $oddsVal = $this->odds->oddsValueForScope($snapshot, $tier); + $lines = []; + $total = 0; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + if ((string) $c->number_4d !== $target) { + continue; + } + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $c->number_4d, 'bet_amount' => $bet, 'payout' => $payout]; + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $total > 0 ? $tier : null, + 'match_detail' => ['lines' => $lines, 'target' => $target], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/Pos4ListTierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos4ListTierSettlementMatcher.php new file mode 100644 index 0000000..232ef45 --- /dev/null +++ b/app/Services/Settlement/Matchers/Pos4ListTierSettlementMatcher.php @@ -0,0 +1,56 @@ + */ + private const PLAY_TO_TIER = [ + 'pos_4d' => 'starter', + 'pos_4e' => 'consolation', + ]; + + public function __construct( + private readonly OddsSnapshotReader $odds, + ) {} + + public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array + { + $tier = self::PLAY_TO_TIER[$item->play_code] ?? 'starter'; + $targets = array_flip($board->numbersForPrizeType($tier)); + if ($targets === []) { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_targets']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $oddsVal = $this->odds->oddsValueForScope($snapshot, $tier); + $lines = []; + $total = 0; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $n = (string) $c->number_4d; + if (! isset($targets[$n])) { + continue; + } + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = ['number_4d' => $n, 'bet_amount' => $bet, 'payout' => $payout, 'tier' => $tier]; + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $total > 0 ? $tier : null, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/SmallSpreadSettlementMatcher.php b/app/Services/Settlement/Matchers/SmallSpreadSettlementMatcher.php new file mode 100644 index 0000000..c4fd357 --- /dev/null +++ b/app/Services/Settlement/Matchers/SmallSpreadSettlementMatcher.php @@ -0,0 +1,59 @@ +odds_snapshot_json) ? $item->odds_snapshot_json : null; + $lines = []; + $total = 0; + $bestTier = null; + $bestRank = 99; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + $hit = $board->bestSmallTierForNumber((string) $c->number_4d); + if ($hit === null) { + continue; + } + $tier = $hit['tier']; + $oddsVal = $this->odds->oddsValueForScope($snapshot, $tier); + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = [ + 'number_4d' => $c->number_4d, + 'matched_tier' => $tier, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $payout, + ]; + if ($hit['rank'] < $bestRank) { + $bestRank = $hit['rank']; + $bestTier = $tier; + } + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $bestTier, + 'match_detail' => ['lines' => $lines], + ]; + } +} diff --git a/app/Services/Settlement/Matchers/StraightLikeSettlementMatcher.php b/app/Services/Settlement/Matchers/StraightLikeSettlementMatcher.php new file mode 100644 index 0000000..058b001 --- /dev/null +++ b/app/Services/Settlement/Matchers/StraightLikeSettlementMatcher.php @@ -0,0 +1,57 @@ +firstPrizeNumber4d(); + if ($target === '') { + return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_first_prize']]; + } + + $snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null; + $oddsVal = $this->odds->oddsValueForScope($snapshot, 'first'); + $lines = []; + $total = 0; + + foreach ($combinations as $c) { + /** @var TicketCombination $c */ + if ((string) $c->number_4d !== $target) { + continue; + } + $bet = (int) $c->bet_amount; + $payout = (int) floor($bet * ($oddsVal / 10_000)); + $total += $payout; + $lines[] = [ + 'number_4d' => $c->number_4d, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $payout, + ]; + } + + return [ + 'win_amount' => $total, + 'matched_prize_tier' => $total > 0 ? 'first' : null, + 'match_detail' => ['lines' => $lines, 'first_prize' => $target], + ]; + } +} diff --git a/app/Services/Settlement/OddsSnapshotReader.php b/app/Services/Settlement/OddsSnapshotReader.php new file mode 100644 index 0000000..06ce391 --- /dev/null +++ b/app/Services/Settlement/OddsSnapshotReader.php @@ -0,0 +1,26 @@ +>|null $snapshot + */ + public function oddsValueForScope(?array $snapshot, string $scope): int + { + if ($snapshot === null) { + return 0; + } + foreach ($snapshot as $row) { + if (($row['prize_scope'] ?? null) === $scope) { + return (int) ($row['odds_value'] ?? 0); + } + } + + return 0; + } +} diff --git a/app/Services/Settlement/PublishedDrawResultBoard.php b/app/Services/Settlement/PublishedDrawResultBoard.php new file mode 100644 index 0000000..ef84d6d --- /dev/null +++ b/app/Services/Settlement/PublishedDrawResultBoard.php @@ -0,0 +1,128 @@ + prize_type => 越小越优 */ + private const TIER_RANK = [ + 'first' => 0, + 'second' => 1, + 'third' => 2, + 'starter' => 3, + 'consolation' => 4, + ]; + + /** @var Collection */ + private readonly Collection $items; + + private string $firstPrizeNumber = ''; + + /** @var array */ + private array $numberToBestTier = []; + + /** + * @param Collection $items + */ + public function __construct(Collection $items) + { + $this->items = $items; + + $first = $items->firstWhere(fn (DrawResultItem $r) => $r->prize_type === 'first' && (int) $r->prize_index === 0); + $this->firstPrizeNumber = $first !== null ? (string) $first->number_4d : ''; + + foreach ($items as $row) { + $num = (string) $row->number_4d; + if ($num === '') { + continue; + } + $tier = (string) $row->prize_type; + $rank = self::TIER_RANK[$tier] ?? 99; + if (! isset($this->numberToBestTier[$num]) || $rank < $this->numberToBestTier[$num]['rank']) { + $this->numberToBestTier[$num] = ['tier' => $tier, 'rank' => $rank]; + } + } + } + + /** @return Collection */ + public function allRows(): Collection + { + return $this->items; + } + + public function row(string $prizeType, int $prizeIndex = 0): ?DrawResultItem + { + return $this->items->firstWhere( + fn (DrawResultItem $r) => (string) $r->prize_type === $prizeType && (int) $r->prize_index === $prizeIndex, + ); + } + + /** + * @return list + */ + public function numbersForPrizeType(string $prizeType): array + { + $out = []; + foreach ($this->items as $row) { + if ((string) $row->prize_type !== $prizeType) { + continue; + } + $n = (string) $row->number_4d; + if ($n !== '') { + $out[] = $n; + } + } + + return $out; + } + + public function firstPrizeNumber4d(): string + { + return $this->firstPrizeNumber; + } + + public function suffix3ForTier(string $prizeType, int $prizeIndex = 0): string + { + $r = $this->row($prizeType, $prizeIndex); + + return $r !== null ? (string) $r->suffix_3d : ''; + } + + public function suffix2ForTier(string $prizeType, int $prizeIndex = 0): string + { + $r = $this->row($prizeType, $prizeIndex); + + return $r !== null ? (string) $r->suffix_2d : ''; + } + + /** + * Big:任意 23 档中最佳命中档。 + * + * @return array{tier: string, rank: int}|null + */ + public function bestTierForNumber(string $number4d): ?array + { + return $this->numberToBestTier[$number4d] ?? null; + } + + /** + * Small:仅头 / 二 / 三奖(rank 0–2)。 + * + * @return array{tier: string, rank: int}|null + */ + public function bestSmallTierForNumber(string $number4d): ?array + { + $hit = $this->bestTierForNumber($number4d); + if ($hit === null) { + return null; + } + + return $hit['rank'] <= 2 ? $hit : null; + } +} diff --git a/app/Services/Settlement/SettlementMatcherRegistry.php b/app/Services/Settlement/SettlementMatcherRegistry.php new file mode 100644 index 0000000..aef31fa --- /dev/null +++ b/app/Services/Settlement/SettlementMatcherRegistry.php @@ -0,0 +1,51 @@ + $this->straight, + 'big', 'ibox', 'mbox', 'box', 'half_box' => $this->big, + 'small' => $this->small, + 'pos_4a', 'pos_4b', 'pos_4c' => $this->pos4Exact, + 'pos_4d', 'pos_4e' => $this->pos4List, + 'pos_3a', 'pos_3b', 'pos_3c' => $this->pos3Tier, + 'pos_3abc' => $this->pos3Abc, + 'pos_2a', 'pos_2b', 'pos_2c' => $this->pos2Tier, + 'pos_2abc' => $this->pos2Abc, + 'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => $this->firstPrizeCombo, + default => $this->noop, + }; + } +} diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php new file mode 100644 index 0000000..9e44571 --- /dev/null +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -0,0 +1,224 @@ +whereKey($draw->id)->lockForUpdate()->firstOrFail(); + + if ($locked->status === DrawStatus::Settled->value) { + return false; + } + + if ($locked->status !== DrawStatus::Settling->value) { + return false; + } + + $publishedBatch = DrawResultBatch::query() + ->where('draw_id', $locked->id) + ->where('status', DrawResultBatchStatus::Published->value) + ->where('result_version', (int) $locked->current_result_version) + ->orderByDesc('id') + ->first(); + + if ($publishedBatch === null) { + return false; + } + + $existingDone = SettlementBatch::query() + ->where('draw_id', $locked->id) + ->where('result_batch_id', $publishedBatch->id) + ->where('status', SettlementBatchStatus::Completed->value) + ->first(); + + if ($existingDone !== null) { + $locked->forceFill([ + 'status' => DrawStatus::Settled->value, + 'settle_version' => (int) $existingDone->settle_version, + ])->save(); + + return true; + } + + $items = DrawResultItem::query() + ->where('result_batch_id', $publishedBatch->id) + ->orderBy('id') + ->get(); + + $board = new PublishedDrawResultBoard($items); + + $nextSettleVersion = (int) $locked->settle_version + 1; + + $batchRow = SettlementBatch::query()->create([ + 'draw_id' => $locked->id, + 'result_batch_id' => $publishedBatch->id, + 'settle_version' => $nextSettleVersion, + 'status' => SettlementBatchStatus::Running->value, + 'started_at' => now(), + ]); + + $ticketItems = TicketItem::query() + ->where('draw_id', $locked->id) + ->where('status', 'success') + ->with(['combinations', 'order']) + ->orderBy('id') + ->get(); + + /** @var list $prepared */ + $prepared = []; + foreach ($ticketItems as $item) { + $matcher = $this->matchers->for((string) $item->play_code); + $result = $matcher->match($item, $board, $item->combinations); + $gross = max(0, (int) $result['win_amount']); + $tier = $result['matched_prize_tier'] ?? null; + $tier = is_string($tier) ? $tier : null; + $net = $this->payoutAdjuster->adjustGrossWin($gross, $item); + $prepared[] = [ + 'item' => $item, + 'gross_win' => $gross, + 'matched_tier' => $tier, + 'net_win' => $net, + 'match_detail' => $result['match_detail'], + ]; + } + + $currency = strtoupper((string) ($ticketItems->first()?->order?->currency_code ?? 'NPR')); + $pool = JackpotPool::query() + ->where('currency_code', $currency) + ->where('status', 1) + ->lockForUpdate() + ->first(); + + $allocations = []; + $totalJackpotPayout = 0; + if ($pool !== null) { + $burstInput = collect($prepared)->map(fn (array $p): array => [ + 'item' => $p['item'], + 'matched_tier' => $p['matched_tier'], + 'gross_win' => $p['gross_win'], + ]); + $burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput); + $allocations = $burstOut['allocations']; + $totalJackpotPayout = (int) $burstOut['pool_payout']; + } + + $playerTotals = []; + $ticketCount = 0; + $winCount = 0; + $totalPayout = 0; + + foreach ($prepared as $p) { + /** @var TicketItem $item */ + $item = $p['item']; + $ticketCount++; + $net = (int) $p['net_win']; + $jackpotShare = (int) ($allocations[(int) $item->id] ?? 0); + $finalCredit = $net + $jackpotShare; + + TicketSettlementDetail::query()->create([ + 'settlement_batch_id' => $batchRow->id, + 'ticket_item_id' => $item->id, + 'matched_prize_tier' => $p['matched_tier'], + 'win_amount' => $net, + 'jackpot_allocation_amount' => $jackpotShare, + 'match_detail_json' => $p['match_detail'], + ]); + + $item->forceFill([ + 'win_amount' => $net, + 'jackpot_win_amount' => $jackpotShare, + 'settled_at' => now(), + 'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose', + ])->save(); + + if ($finalCredit > 0) { + $winCount++; + } + $totalPayout += $finalCredit; + + $pid = (int) $item->player_id; + $playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit; + + $locks = []; + foreach ($item->combinations as $c) { + $locks[] = [ + 'number_4d' => (string) $c->number_4d, + 'amount' => (int) $c->estimated_payout, + ]; + } + $this->riskPool->release((int) $locked->id, $item, $locks); + } + + foreach ($playerTotals as $playerId => $amount) { + if ($amount <= 0) { + continue; + } + $player = Player::query()->whereKey($playerId)->firstOrFail(); + $this->wallet->creditSettlementPayout($player, $currency, $amount, (int) $batchRow->id); + } + + $batchRow->forceFill([ + 'status' => SettlementBatchStatus::Completed->value, + 'total_ticket_count' => $ticketCount, + 'total_win_count' => $winCount, + 'total_payout_amount' => $totalPayout, + 'total_jackpot_payout_amount' => $totalJackpotPayout, + 'finished_at' => now(), + ])->save(); + + $locked->forceFill([ + 'status' => DrawStatus::Settled->value, + 'settle_version' => $nextSettleVersion, + ])->save(); + + foreach ($ticketItems->pluck('order_id')->unique()->all() as $orderId) { + $pending = TicketItem::query() + ->where('order_id', $orderId) + ->whereNotIn('status', ['settled_win', 'settled_lose']) + ->exists(); + if (! $pending) { + TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']); + } + } + + return true; + }); + } +} diff --git a/app/Services/Settlement/SettlementPayoutAdjuster.php b/app/Services/Settlement/SettlementPayoutAdjuster.php new file mode 100644 index 0000000..ea608a9 --- /dev/null +++ b/app/Services/Settlement/SettlementPayoutAdjuster.php @@ -0,0 +1,27 @@ +rebate_rate_snapshot; + + return (int) floor($grossWin * max(0.0, 1.0 - $rebate)); + } +} diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index ef5f3a7..a819ef9 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -10,6 +10,7 @@ use App\Models\Player; use App\Models\TicketCombination; use App\Models\TicketItem; use App\Models\TicketOrder; +use App\Services\Jackpot\JackpotContributionService; use Illuminate\Support\Facades\DB; final class TicketPlacementService @@ -19,6 +20,7 @@ final class TicketPlacementService private readonly PlayRuleEngine $ruleEngine, private readonly RiskPoolService $riskPoolService, private readonly TicketWalletService $ticketWalletService, + private readonly JackpotContributionService $jackpotContribution, ) {} /** @@ -151,6 +153,8 @@ final class TicketPlacementService $lockedAmount = $this->riskPoolService->acquire((int) $draw->id, $item, $locks); $item->forceFill(['risk_locked_amount' => $lockedAmount])->save(); + + $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, $currencyCode); } return $order; diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index a2a3975..58f5fe5 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -15,6 +15,8 @@ final class TicketWalletService private const TXN_DIR_OUT = 2; + private const TXN_DIR_IN = 1; + public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): void { $wallet = PlayerWallet::query() @@ -65,6 +67,61 @@ final class TicketWalletService ]); } + /** + * 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。 + */ + public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void + { + if ($amountMinor <= 0) { + return; + } + + $currency = strtoupper($currencyCode); + + $wallet = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', $currency) + ->lockForUpdate() + ->first(); + + if ($wallet === null) { + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => $currency, + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); + } + + $before = (int) $wallet->balance; + $after = $before + $amountMinor; + $wallet->forceFill([ + 'balance' => $after, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'settle_payout', + 'biz_no' => 'SB'.$settlementBatchId, + 'direction' => self::TXN_DIR_IN, + 'amount' => $amountMinor, + 'balance_before' => $before, + 'balance_after' => $after, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => 'settle-payout:'.$settlementBatchId.':'.$player->id, + 'remark' => null, + ]); + } + private function newTxnNo(): string { return 'WL'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); diff --git a/database/seeders/LotterySettingsSeeder.php b/database/seeders/LotterySettingsSeeder.php index d91335d..ab06c7b 100644 --- a/database/seeders/LotterySettingsSeeder.php +++ b/database/seeders/LotterySettingsSeeder.php @@ -60,5 +60,19 @@ class LotterySettingsSeeder extends Seeder 'general', '客户端展示用短名称(示例)', ); + + LotterySettings::put( + 'settlement.auto_run_on_tick', + true, + 'settlement', + '是否在 draw tick 中自动对 `settling` 期号跑结算;false 时仅能通过后台 POST settlement/run 触发', + ); + + LotterySettings::put( + 'settlement.apply_rebate_to_payout', + false, + 'settlement', + '为 true 时结算派彩在毛赢基础上再乘 (1 - rebate_rate_snapshot);默认 false(实扣已含回水)', + ); } } diff --git a/routes/api.php b/routes/api.php index de7d49d..e387022 100644 --- a/routes/api.php +++ b/routes/api.php @@ -21,6 +21,11 @@ use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawResultBatchPublishController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawSettlementRunController; +use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexController; +use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPayoutLogIndexController; +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\PingController as AdminPingController; use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController; use App\Http\Controllers\Api\V1\Admin\PlayTypeIndexController; @@ -28,15 +33,22 @@ use App\Http\Controllers\Api\V1\Admin\PlayTypePatchController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolIndexController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolLockLogIndexController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolShowController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchDetailsController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchIndexController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchShowController; use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController; use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; use App\Http\Controllers\Api\V1\Draw\DrawResultShowController; use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController; use App\Http\Controllers\Api\V1\HealthController; +use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Player\MeController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; +use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController; +use App\Http\Controllers\Api\V1\Ticket\TicketItemShowController; +use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController; use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController; use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController; use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController; @@ -61,6 +73,8 @@ Route::prefix('v1')->group(function (): void { ->where('draw_no', '[0-9]{8}-[0-9]{3}') ->name('api.v1.draw.results.show'); + Route::get('jackpot/summary', JackpotSummaryController::class)->name('api.v1.jackpot.summary'); + // 名称:生效玩法 / 赔率 / 封顶目录(阶段 4;公开) Route::get('play/effective', PlayEffectiveCatalogController::class)->name('api.v1.play.effective'); @@ -97,6 +111,13 @@ Route::prefix('v1')->group(function (): void { ->group(function (): void { Route::post('preview', TicketPreviewController::class)->name('preview'); Route::post('place', TicketPlaceController::class)->name('place'); + Route::get('items', TicketItemsIndexController::class)->name('items.index'); + Route::get('items/{ticket_no}', TicketItemShowController::class) + ->where('ticket_no', 'TK[0-9]+') + ->name('items.show'); + Route::get('draws/{draw_no}/my-match', TicketDrawMyMatchController::class) + ->where('draw_no', '[0-9]{8}-[0-9]{3}') + ->name('draws.my-match'); }); }); @@ -139,6 +160,22 @@ Route::prefix('v1')->group(function (): void { 'draws/{draw}/result-batches/{batch}/publish', DrawResultBatchPublishController::class, )->name('draws.result-batches.publish'); + Route::post('draws/{draw}/settlement/run', DrawSettlementRunController::class) + ->name('draws.settlement.run'); + + Route::get('settlement-batches', AdminSettlementBatchIndexController::class) + ->name('settlement-batches.index'); + Route::get('settlement-batches/{batch}', AdminSettlementBatchShowController::class) + ->name('settlement-batches.show'); + Route::get('settlement-batches/{batch}/details', AdminSettlementBatchDetailsController::class) + ->name('settlement-batches.details'); + + Route::get('jackpot/pools', AdminJackpotPoolIndexController::class)->name('jackpot.pools.index'); + Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)->name('jackpot.pools.update'); + Route::get('jackpot/payout-logs', AdminJackpotPayoutLogIndexController::class) + ->name('jackpot.payout-logs.index'); + Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class) + ->name('jackpot.contributions.index'); // 阶段 4:玩法目录 + 赔率 + 风控封顶(版本化管理) Route::get('play-types', PlayTypeIndexController::class)->name('play-types.index'); diff --git a/tests/Feature/AdminSettlementJackpotApiTest.php b/tests/Feature/AdminSettlementJackpotApiTest.php new file mode 100644 index 0000000..939e870 --- /dev/null +++ b/tests/Feature/AdminSettlementJackpotApiTest.php @@ -0,0 +1,45 @@ +create([ + 'username' => 'settlement_admin', + 'name' => 'Settlement QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin settlement batches index is authenticated', function (): void { + $this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized(); +}); + +test('admin jackpot pools index returns rows', function (): void { + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 100, + 'contribution_rate' => '0.01', + 'trigger_threshold' => 1000, + 'payout_rate' => '0.5', + 'force_trigger_draw_gap' => 10, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + + $token = mintSettlementAdminToken(); + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/jackpot/pools') + ->assertOk() + ->assertJsonPath('data.items.0.currency_code', 'NPR'); +}); diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index d186211..cd622ba 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -1,13 +1,14 @@ tick(now()->utc()); $draw->refresh(); - expect($draw->status)->toBe(DrawStatus::Settling->value); + expect($draw->status)->toBe(DrawStatus::Settled->value); + expect((int) $draw->settle_version)->toBe(1); + expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'completed')->count())->toBe(1); Carbon::setTestNow(); }); diff --git a/tests/Feature/JackpotPlacementSettlementTest.php b/tests/Feature/JackpotPlacementSettlementTest.php new file mode 100644 index 0000000..c27a6f5 --- /dev/null +++ b/tests/Feature/JackpotPlacementSettlementTest.php @@ -0,0 +1,141 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void { + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 0, + 'contribution_rate' => '0.1000', + 'trigger_threshold' => 1, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'jp-p-'.$uniq, + 'username' => 'jp_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 5_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260511-901', + 'business_date' => '2026-05-11', + 'sequence_no' => 901, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-901', + 'currency_code' => 'NPR', + 'client_trace_id' => 'jp-trace-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + expect(JackpotContribution::query()->count())->toBe(1); + $poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + expect((int) $poolAfterBet->current_amount)->toBe(1_000); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'test', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; + $suffix3 = substr($num, -3); + $suffix2 = substr($num, -2); + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => $suffix3, + 'suffix_2d' => $suffix2, + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + $ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); + expect($ran)->toBeTrue(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $item->win_amount)->toBe(250_000); + expect((int) $item->jackpot_win_amount)->toBe(1_000); + + $poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + expect((int) $poolAfterSettle->current_amount)->toBe(0); + expect(JackpotPayoutLog::query()->count())->toBe(1); + + $order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail(); + expect($order->status)->toBe('settled'); +}); diff --git a/tests/Feature/SettlementMatcherRegistryCompletenessTest.php b/tests/Feature/SettlementMatcherRegistryCompletenessTest.php new file mode 100644 index 0000000..2ff8fa7 --- /dev/null +++ b/tests/Feature/SettlementMatcherRegistryCompletenessTest.php @@ -0,0 +1,24 @@ + $this->seed(PlayTypeSeeder::class)); + +test('every play_types.play_code maps to a non-noop settlement matcher', function (): void { + $reg = app(SettlementMatcherRegistry::class); + foreach (PlayType::query()->orderBy('play_code')->pluck('play_code') as $code) { + $matcher = $reg->for((string) $code); + expect($matcher)->not->toBeInstanceOf(NoopSettlementMatcher::class); + } +}); + +test('half_box reuses the same matcher instance as big spread', function (): void { + $reg = app(SettlementMatcherRegistry::class); + expect($reg->for('half_box'))->toBe($reg->for('big')); +}); diff --git a/tests/Feature/SettlementOrchestratorTest.php b/tests/Feature/SettlementOrchestratorTest.php new file mode 100644 index 0000000..99dfed6 --- /dev/null +++ b/tests/Feature/SettlementOrchestratorTest.php @@ -0,0 +1,131 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +test('settlement pays big winner and marks ticket settled', function (): void { + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'settle-p-'.$uniq, + 'username' => 'sp_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 5_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260511-900', + 'business_date' => '2026-05-11', + 'sequence_no' => 900, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-900', + 'currency_code' => 'NPR', + 'client_trace_id' => 'settle-trace-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'test', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; + $suffix3 = substr($num, -3); + $suffix2 = substr($num, -2); + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => $suffix3, + 'suffix_2d' => $suffix2, + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + $ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); + expect($ran)->toBeTrue(); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Settled->value); + expect((int) $draw->settle_version)->toBe(1); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect($item->status)->toBe('settled_win'); + expect((int) $item->win_amount)->toBe(250_000); + + $order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail(); + expect($order->status)->toBe('settled'); + + expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(5_000_000 - (int) $item->actual_deduct_amount + 250_000); + + expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1); +}); diff --git a/tests/Feature/SettlementPhase145AcceptanceTest.php b/tests/Feature/SettlementPhase145AcceptanceTest.php new file mode 100644 index 0000000..3b28e0b --- /dev/null +++ b/tests/Feature/SettlementPhase145AcceptanceTest.php @@ -0,0 +1,681 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +function p145_player(int $balance = 5_000_000): Player +{ + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'p145-'.$uniq, + 'username' => 'p145_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => $balance, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + return $player; +} + +/** 路由 `draw_no` 约束为 `YYYYMMDD-NNN`(序数三位)。 */ +function p145_next_draw_no(): string +{ + static $i = 0; + $i++; + + return sprintf('20260511-%03d', 400 + ($i % 500)); +} + +function p145_draw(string $drawNo, int $sequenceNo): Draw +{ + return Draw::query()->create([ + 'draw_no' => $drawNo, + 'business_date' => '2026-05-11', + 'sequence_no' => $sequenceNo, + '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, + ]); +} + +/** + * @param Closure(string $prizeType, int $prizeIndex): string $numberFor + */ +function p145_publish_board(Draw $draw, Closure $numberFor): void +{ + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'p145', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $numberFor($slot['prize_type'], (int) $slot['prize_index']); + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } +} + +/** 23 格不含 8888,用于 Big 未中奖。 */ +function p145_board_without_8888(string $prizeType, int $prizeIndex): string +{ + return match ($prizeType) { + 'first' => '1111', + 'second' => '2222', + 'third' => '3333', + 'starter' => sprintf('41%02d', $prizeIndex), + 'consolation' => sprintf('52%02d', $prizeIndex), + }; +} + +test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_payout txn', function (): void { + $player = p145_player(); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-lose-1', + 'lines' => [ + ['number' => '8888', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $deduct = (int) $item->actual_deduct_amount; + + p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i)); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $item->refresh(); + expect($item->status)->toBe('settled_lose') + ->and((int) $item->win_amount)->toBe(0) + ->and((int) $item->jackpot_win_amount)->toBe(0); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(5_000_000 - $deduct); + + expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0); + expect(TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->count())->toBe(1); + + $ticketNo = $item->ticket_no; + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items/'.$ticketNo) + ->assertOk() + ->assertJsonPath('data.status', 'settled_lose') + ->assertJsonPath('data.win_amount', 0); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/draws/'.$drawNo.'/my-match') + ->assertOk() + ->assertJsonPath('data.has_bets', true) + ->assertJsonPath('data.hit_numbers_4d', []) + ->assertJsonPath('data.total_win_minor', 0); +}); + +test('§14.5 small hits second tier only', function (): void { + $player = p145_player(); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-small-1', + 'lines' => [ + ['number' => '8888', 'play_code' => 'small', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $deduct = (int) $item->actual_deduct_amount; + $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['second'] / 10_000); + + p145_publish_board($draw, function (string $t, int $i): string { + return match ($t) { + 'first' => '1001', + 'second' => '8888', + 'third' => '2002', + 'starter' => sprintf('30%02d', $i), + 'consolation' => sprintf('40%02d', $i), + }; + }); + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $item->refresh(); + expect($item->status)->toBe('settled_win') + ->and((int) $item->win_amount)->toBe($expectedWin); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin); + expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1); +}); + +test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', function (): void { + $cases = [ + [ + 'play' => 'pos_4b', + 'number' => '7777', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1111', + 'second' => '7777', + 'third' => '3333', + 'starter' => sprintf('51%02d', $i), + 'consolation' => sprintf('62%02d', $i), + }, + 'scope' => 'second', + ], + [ + 'play' => 'pos_3a', + 'number' => '234', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1234', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'first', + ], + [ + 'play' => 'pos_2a', + 'number' => '34', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1234', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'first', + ], + [ + 'play' => 'pos_4e', + 'number' => '7777', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1111', + 'second' => '2222', + 'third' => '3333', + 'starter' => sprintf('51%02d', $i), + 'consolation' => $i === 4 ? '7777' : sprintf('62%02d', $i), + }, + 'scope' => 'consolation', + ], + ]; + + foreach ($cases as $case) { + $player = p145_player(); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-'.$case['play'].'-'.uniqid('', true), + 'lines' => [ + ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $deduct = (int) $item->actual_deduct_amount; + $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; + $perComboWin = (int) floor(10_000 * $odds / 10_000); + $comboCount = (int) $item->combination_count; + $expectedWin = match ($case['play']) { + 'pos_3a', 'pos_2a' => $perComboWin * $comboCount, + default => $perComboWin, + }; + + p145_publish_board($draw, $case['board']); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $item->refresh(); + expect($item->status)->toBe('settled_win', $case['play']) + ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin, $case['play']); + } +}); + +test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void { + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 0, + 'contribution_rate' => '0.1000', + 'trigger_threshold' => 1, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + + $player = p145_player(); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-jp-keep', + 'lines' => [ + ['number' => '8888', 'play_code' => 'small', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + expect(JackpotContribution::query()->count())->toBe(1); + $poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + expect((int) $poolAfterBet->current_amount)->toBe(1_000); + + p145_publish_board($draw, function (string $t, int $i): string { + return match ($t) { + 'first' => '1001', + 'second' => '8888', + 'third' => '2002', + 'starter' => sprintf('30%02d', $i), + 'consolation' => sprintf('40%02d', $i), + }; + }); + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + expect(JackpotPayoutLog::query()->count())->toBe(0); + $poolAfter = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + expect((int) $poolAfter->current_amount)->toBe(1_000); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $item->jackpot_win_amount)->toBe(0); +}); + +test('§14.5 placement rollback returns stake when mid-order risk acquire fails (退本)', function (): void { + $player = p145_player(500_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 5000, + 'locked_amount' => 0, + 'remaining_amount' => 5000, + 'sold_out_status' => 0, + 'version' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-rollback', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 120], + ['number' => '1234', 'play_code' => 'big', 'amount' => 120], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value); + + expect(TicketOrder::query()->count())->toBe(0); + expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(500_000); +}); + +/** + * 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。 + * `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。 + */ +test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants settle win', function (): void { + $cases = [ + [ + 'play' => 'straight', + 'line' => ['number' => '8881', 'play_code' => 'straight', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '8881' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'roll', + 'line' => ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '5234' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'box', + 'line' => ['number' => '1357', 'play_code' => 'box', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '7135' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'ibox', + 'line' => ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '1212' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'mbox', + 'line' => ['number' => '2468', 'play_code' => 'mbox', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '8642' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'head', + 'line' => ['number' => '6', 'play_code' => 'head', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '6781' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'tail', + 'line' => ['number' => '2', 'play_code' => 'tail', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '2342' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'odd', + 'line' => ['number' => '1', 'play_code' => 'odd', 'amount' => 10_000, 'dimension' => 'D4'], + 'first_combo_board' => true, + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'even', + 'line' => ['number' => '0', 'play_code' => 'even', 'amount' => 10_000, 'dimension' => 'D4'], + 'first_combo_board' => true, + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'digit_big', + 'line' => ['number' => '9', 'play_code' => 'digit_big', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 2], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '1299' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'digit_small', + 'line' => ['number' => '1', 'play_code' => 'digit_small', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 1], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '3142' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'pos_4a', + 'line' => ['number' => '6006', 'play_code' => 'pos_4a', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => $t === 'first' ? '6006' : p145_board_without_8888($t, $i), + 'scope' => 'first', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'pos_4c', + 'line' => ['number' => '4004', 'play_code' => 'pos_4c', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'third' => '4004', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'third', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'pos_4d', + 'line' => ['number' => '5555', 'play_code' => 'pos_4d', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'starter' => $i === 3 ? '5555' : sprintf('71%02d', $i), + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'starter', + 'comboMultiplier' => 1, + ], + [ + 'play' => 'pos_3b', + 'line' => ['number' => '949', 'play_code' => 'pos_3b', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'second' => '2949', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'second', + 'comboMultiplier' => 10, + ], + [ + 'play' => 'pos_3c', + 'line' => ['number' => '678', 'play_code' => 'pos_3c', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'third' => '9678', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'third', + 'comboMultiplier' => 10, + ], + [ + 'play' => 'pos_3abc', + 'line' => ['number' => '567', 'play_code' => 'pos_3abc', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '4567', + 'second' => '8123', + 'third' => '9234', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'first', + 'comboMultiplier' => 10, + ], + [ + 'play' => 'pos_2b', + 'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1111', + 'second' => '7856', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'second', + 'comboMultiplier' => 100, + ], + [ + 'play' => 'pos_2c', + 'line' => ['number' => '30', 'play_code' => 'pos_2c', 'amount' => 10_000], + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '9999', + 'second' => '8888', + 'third' => '7830', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'third', + 'comboMultiplier' => 100, + ], + [ + 'play' => 'pos_2abc', + 'line' => ['number' => '99', 'play_code' => 'pos_2abc', 'amount' => 100], + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '8899', + 'second' => '2299', + 'third' => '1199', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'first', + 'comboMultiplier' => 100, + ], + ]; + + foreach ($cases as $case) { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-all-'.$case['play'].'-'.uniqid('', true), + 'lines' => [$case['line']], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $deduct = (int) $item->actual_deduct_amount; + $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; + $unitOnTicket = (int) $item->unit_bet_amount; + $perComboWin = (int) floor($unitOnTicket * $odds / 10_000); + $expectedWin = $perComboWin * (int) $case['comboMultiplier']; + + $board = $case['board'] ?? null; + if ($case['first_combo_board'] ?? false) { + $target = (string) TicketCombination::query() + ->where('ticket_item_id', $item->id) + ->orderBy('combination_no') + ->value('number_4d'); + $board = fn (string $t, int $i): string => $t === 'first' ? $target : p145_board_without_8888($t, $i); + } + expect($board)->toBeInstanceOf(Closure::class, $case['play']); + + p145_publish_board($draw, $board); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); + + $item->refresh(); + expect($item->status)->toBe('settled_win', $case['play']) + ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin, $case['play']); + } +}); + +test('§14.6 ticket detail shows settlement tier after win', function (): void { + $player = p145_player(); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-detail', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); + expect($ticketNo)->not->toBeEmpty(); + + p145_publish_board($draw, function (string $t, int $i): string { + $num = $t === 'first' ? '1234' : '5678'; + + return $num; + }); + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items/'.$ticketNo) + ->assertOk() + ->assertJsonPath('data.status', 'settled_win') + ->assertJsonPath('data.settlement.matched_prize_tier', 'first'); +}); diff --git a/tests/Feature/TicketItemsApiTest.php b/tests/Feature/TicketItemsApiTest.php new file mode 100644 index 0000000..8233235 --- /dev/null +++ b/tests/Feature/TicketItemsApiTest.php @@ -0,0 +1,203 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +test('jackpot summary is public', function (): void { + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 1_234_000, + 'contribution_rate' => '0.0100', + 'trigger_threshold' => 0, + 'payout_rate' => '0.5000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + + $this->getJson('/api/v1/jackpot/summary?currency_code=NPR') + ->assertOk() + ->assertJsonPath('data.enabled', true) + ->assertJsonPath('data.current_amount_minor', 1_234_000); +}); + +test('ticket items index returns placed ticket for player', function (): void { + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'items-p-'.$uniq, + 'username' => 'ti_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 5_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260511-777', + 'business_date' => '2026-05-11', + 'sequence_no' => 777, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-777', + 'currency_code' => 'NPR', + 'client_trace_id' => 'items-trace-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.draw_no', '20260511-777') + ->assertJsonPath('data.items.0.play_code', 'big'); + + $ticketNo = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items') + ->json('data.items.0.ticket_no'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items/'.$ticketNo) + ->assertOk() + ->assertJsonPath('data.ticket_no', $ticketNo) + ->assertJsonPath('data.combinations.0.number_4d', '1234'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777')) + ->assertOk() + ->assertJsonPath('data.total', 1); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-000')) + ->assertOk() + ->assertJsonPath('data.total', 0); +}); + +test('my-match returns hit numbers when draw published', function (): void { + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'match-p-'.$uniq, + 'username' => 'tm_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 5_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260511-778', + 'business_date' => '2026-05-11', + 'sequence_no' => 778, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-778', + 'currency_code' => 'NPR', + 'client_trace_id' => 'match-trace-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'test', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + $draw->forceFill([ + 'status' => DrawStatus::Cooldown->value, + 'current_result_version' => 1, + ])->save(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/draws/20260511-778/my-match') + ->assertOk() + ->assertJsonPath('data.has_bets', true) + ->assertJsonPath('data.hit_numbers_4d', ['1234']); +});